--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 389034dbeaa7b0deaa33a77c27ad3a1ac903d6d1
Parents : 58bfaf3
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-16T16:17:54-05:00
feat: integrate visualiser WASM build into Dockerfile and update frontend to handle codec2 availability
Changes
44 files changed, 2192 insertions(+), 3549 deletions(-)
Diff
diff --git a/.dockerignore b/.dockerignore
index 1bea410a..d218979d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -140,6 +140,10 @@ scripts/ci/
meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm
meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js
+# Host-built visualiser WASM (Docker builds with Go from visualiser-wasm/)
+meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm
+meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js
+
.hypothesis
.hypothesis/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19408fce..297bf85e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file.
- CI benches use median-of-medians and quieter regression gates
- Backend tests can run sharded in CI
- Plugin strings live in plugin bundles, not main locale files
+- Docker frontend build installs Go, builds visualiser WASM, and fails if WASM artifacts are missing
### Fixed
@@ -37,10 +38,20 @@ All notable changes to this project will be documented in this file.
- Startup check and disable unsupported interfaces
- Nomad favourites: no more Unknown Node / lost custom sections
- Relay Chat message dedupe. Network visualiser faster on large meshes
-- Bots and RNSh work in frozen macOS/Windows builds (`--meshchatx-run-module`)
+- Bots and RNSh work in frozen macOS/Windows builds
- Sensitive config no longer mutable over WebSocket. Reticulum config repair on startup
- Paper message URI encoding for non-ASCII title and content
- Nightly releases and broader self-test / CI coverage
+- LXMA contact import works with current RNS public-key loading and remembers the peer key before announce
+- Android calls: overlay accept opens the phone tab so native audio attaches. Web-audio no longer permanently disabled after a bridge error
+- Android Codec2: reliable libcodec2 preload, Gradle fails without Codec2 wheels or jniLibs, and unavailable Codec2 profiles are hidden
+- Unknown meshchatx links return a clear error instead of falling through to LXMF
+- NomadNet Micron copy no longer inserts a newline between every character
+- Unread message count is a red pill on the Messages nav icon
+- Notification bell removed from the header
+- Unread badge stays circular and remains visible when the sidebar is collapsed
+- Open conversations mark as read when a new message arrives without needing to reselect the thread
+- Startup stage logs no longer print the same stage twice
## [4.7.2] - 2026-07-06
diff --git a/Dockerfile b/Dockerfile
index 6918db77..25c303d0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,7 +16,8 @@ ARG PYTHON_HASH=sha256:dd4d2bd5b53d9b25a51da13addf2be586beebd5387e289e798e4083d9
# ---- STAGE 1: Frontend Build ----
FROM --platform=linux/amd64 ${NODE_IMAGE}@${NODE_HASH} AS build-frontend
WORKDIR /src
-RUN apk add --no-cache git python3
+# go is required to compile visualiser-wasm
+RUN apk add --no-cache git python3 go
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml vite.config.js ./
COPY patches ./patches
COPY scripts/fetch-micron-wasm.mjs scripts/fetch-micron-wasm.mjs
@@ -27,11 +28,19 @@ COPY scripts/sync-meshchatx-docs.js scripts/sync-meshchatx-docs.js
COPY scripts/pip_rns_remotes.py scripts/pip_rns_remotes.py
COPY scripts/build/fetch_reticulum_manual.py scripts/build/fetch_reticulum_manual.py
COPY docs ./docs
+COPY visualiser-wasm ./visualiser-wasm
COPY meshchatx/src/frontend ./meshchatx/src/frontend
-RUN npm install -g pnpm@11.1.2 && \
+ENV GOCACHE=/tmp/go-cache
+ENV GOTMPDIR=/tmp/go-tmp
+RUN mkdir -p /tmp/go-cache /tmp/go-tmp && \
+ npm install -g pnpm@11.1.2 && \
pnpm config set verify-store-integrity true && \
pnpm install --frozen-lockfile && \
- pnpm run build-frontend && \
+ MESHCHATX_REQUIRE_VISUALISER_WASM=1 pnpm run build-frontend && \
+ test -s meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm && \
+ test -s meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js && \
+ test -s meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm && \
+ test -s meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js && \
pnpm run build-docs
# ---- STAGE 2: Python Builder ----
diff --git a/Dockerfile.hardened b/Dockerfile.hardened
index e92e9c5e..1d8c706f 100644
--- a/Dockerfile.hardened
+++ b/Dockerfile.hardened
@@ -13,7 +13,8 @@ ARG PYTHON_RUNTIME_IMAGE=cgr.dev/chainguard/python:latest-dev
FROM --platform=linux/amd64 ${NODE_IMAGE} AS build-frontend
USER root
WORKDIR /src
-RUN apk add --no-cache git python3
+# go is required to compile visualiser-wasm (gitignored binary; not in image context)
+RUN apk add --no-cache git python3 go
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml vite.config.js ./
COPY patches ./patches
COPY scripts/fetch-micron-wasm.mjs scripts/fetch-micron-wasm.mjs
@@ -24,11 +25,19 @@ COPY scripts/sync-meshchatx-docs.js scripts/sync-meshchatx-docs.js
COPY scripts/pip_rns_remotes.py scripts/pip_rns_remotes.py
COPY scripts/build/fetch_reticulum_manual.py scripts/build/fetch_reticulum_manual.py
COPY docs ./docs
+COPY visualiser-wasm ./visualiser-wasm
COPY meshchatx/src/frontend ./meshchatx/src/frontend
-RUN npm install -g pnpm@11.1.2 && \
+ENV GOCACHE=/tmp/go-cache
+ENV GOTMPDIR=/tmp/go-tmp
+RUN mkdir -p /tmp/go-cache /tmp/go-tmp && \
+ npm install -g pnpm@11.1.2 && \
pnpm config set verify-store-integrity true && \
pnpm install --frozen-lockfile && \
- pnpm run build-frontend && \
+ MESHCHATX_REQUIRE_VISUALISER_WASM=1 pnpm run build-frontend && \
+ test -s meshchatx/src/frontend/public/vendor/visualiser-wasm/visualiser.wasm && \
+ test -s meshchatx/src/frontend/public/vendor/visualiser-wasm/wasm_exec.js && \
+ test -s meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm && \
+ test -s meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js && \
pnpm run build-docs
FROM ${PYTHON_BUILD_IMAGE} AS builder
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 815c7b87..567c97fb 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -104,8 +104,20 @@ tasks.register("syncCodec2JniLibs", Exec) {
"${projectDir}/src/main/jniLibs",
abiArg
)
- onlyIf {
- vendorWheelDir.isDirectory() && vendorWheelDir.list()?.any { it.startsWith("chaquopy_libcodec2-") }
+ doFirst {
+ if (!vendorWheelDir.isDirectory()) {
+ throw new org.gradle.api.GradleException(
+ "Missing android/vendor directory at ${vendorWheelDir}. " +
+ "Run: bash scripts/build-android-wheels-local.sh"
+ )
+ }
+ def wheels = vendorWheelDir.list()?.toList() ?: []
+ if (!wheels.any { it.startsWith("chaquopy_libcodec2-") }) {
+ throw new org.gradle.api.GradleException(
+ "Missing chaquopy_libcodec2 wheels in ${vendorWheelDir}. " +
+ "Run: bash scripts/build-android-wheels-local.sh"
+ )
+ }
}
}
@@ -128,6 +140,25 @@ tasks.register("verifyVendorWheels") {
)
}
}
+ if (!wheels.any { it.startsWith("pycodec2-") && it.contains("android_24_${abiTag}") }) {
+ throw new org.gradle.api.GradleException(
+ "Missing pycodec2 wheel for ${abi} in ${vendorWheelDir}. " +
+ "Run: bash scripts/build-android-wheels-local.sh"
+ )
+ }
+ if (!wheels.any { it.startsWith("chaquopy_libcodec2-") && it.contains("android_24_${abiTag}") }) {
+ throw new org.gradle.api.GradleException(
+ "Missing chaquopy_libcodec2 wheel for ${abi} in ${vendorWheelDir}. " +
+ "Run: bash scripts/build-android-wheels-local.sh"
+ )
+ }
+ def jniLib = file("${projectDir}/src/main/jniLibs/${abi}/libcodec2.so")
+ if (!jniLib.isFile() || jniLib.length() < 100_000) {
+ throw new org.gradle.api.GradleException(
+ "Missing or tiny jniLibs/${abi}/libcodec2.so after sync. " +
+ "Expected a real Codec2 shared library for Android calls."
+ )
+ }
}
}
}
@@ -142,8 +173,24 @@ tasks.register("repackAndroidPycodec2Wheels", Exec) {
workingDir = repoRoot
def pyExe = (System.getenv("MESHCHATX_REPACK_PYTHON") ?: "python3")
commandLine(pyExe, "scripts/repack-android-pycodec2-wheels.py", "--vendor-dir", vendorWheelDir.absolutePath)
- onlyIf {
- vendorWheelDir.isDirectory() && vendorWheelDir.list()?.any { it.startsWith("pycodec2-") && it.endsWith(".whl") }
+ doFirst {
+ if (!vendorWheelDir.isDirectory()) {
+ throw new org.gradle.api.GradleException(
+ "Missing android/vendor directory at ${vendorWheelDir}"
+ )
+ }
+ def wheels = vendorWheelDir.list()?.toList() ?: []
+ if (!wheels.any { it.startsWith("pycodec2-") && it.endsWith(".whl") }) {
+ throw new org.gradle.api.GradleException(
+ "Missing pycodec2 wheels in ${vendorWheelDir}. " +
+ "Run: bash scripts/build-android-wheels-local.sh"
+ )
+ }
+ if (!wheels.any { it.startsWith("chaquopy_libcodec2-") && it.endsWith(".whl") }) {
+ throw new org.gradle.api.GradleException(
+ "Missing chaquopy_libcodec2 wheels needed to repack pycodec2 in ${vendorWheelDir}"
+ )
+ }
}
}
@@ -151,6 +198,10 @@ tasks.named("syncCodec2JniLibs").configure {
dependsOn(tasks.named("repackAndroidPycodec2Wheels"))
}
+tasks.named("verifyVendorWheels").configure {
+ dependsOn(tasks.named("syncCodec2JniLibs"))
+}
+
tasks.register("fetchRepositoryBundledWheels", Exec) {
workingDir = repoRoot
def pyExe = (System.getenv("MESHCHATX_FETCH_PYTHON") ?: "python3")
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 007ea419..1e273517 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/android_codec2.py b/meshchatx/android_codec2.py
index debc3897..f6cee32a 100644
--- a/meshchatx/android_codec2.py
+++ b/meshchatx/android_codec2.py
@@ -23,7 +23,24 @@ def _is_chaquopy_android() -> bool:
return True
+def _cdll_load(path_or_name: str):
+ """Load a shared library with RTLD_GLOBAL when the platform supports it.
+
+ pycodec2.so declares NEEDED libcodec2.so. Loading with RTLD_GLOBAL lets the
+ later dlopen of the extension resolve that dependency.
+ """
+ mode = getattr(ctypes, "RTLD_GLOBAL", None)
+ if mode is None:
+ return ctypes.CDLL(path_or_name)
+ return ctypes.CDLL(path_or_name, mode=mode)
+
+
def _libcodec2_candidates() -> list[Path]:
+ """Return candidate paths for libcodec2.so without importing pycodec2.
+
+ ``import pycodec2`` loads the extension which already needs libcodec2.so.
+ Searching sys.path on disk avoids that chicken-and-egg failure.
+ """
candidates: list[Path] = []
seen: set[str] = set()
@@ -34,17 +51,12 @@ def _libcodec2_candidates() -> list[Path]:
seen.add(key)
candidates.append(path)
- try:
- import pycodec2
-
- add(Path(pycodec2.__file__).resolve().parent / "libcodec2.so")
- except Exception:
- pass
-
for entry in sys.path:
if not entry:
continue
- add(Path(entry) / "chaquopy" / "lib" / "libcodec2.so")
+ root = Path(entry)
+ add(root / "pycodec2" / "libcodec2.so")
+ add(root / "chaquopy" / "lib" / "libcodec2.so")
return candidates
@@ -53,7 +65,7 @@ def ensure_codec2_native_library() -> bool:
"""Preload ``libcodec2.so`` so ``import pycodec2`` works on Android.
Chaquopy installs ``chaquopy-libcodec2`` separately from ``pycodec2``. The
- extension module only declares a NEEDED entry for ``libcodec2.so``; without
+ extension module only declares a NEEDED entry for ``libcodec2.so``. Without
preloading or bundling the shared library next to ``pycodec2.so``, imports
fail at runtime with ``dlopen`` errors.
"""
@@ -68,7 +80,7 @@ def ensure_codec2_native_library() -> bool:
return True
try:
- ctypes.CDLL("libcodec2.so")
+ _cdll_load("libcodec2.so")
return True
except OSError:
pass
@@ -78,7 +90,7 @@ def ensure_codec2_native_library() -> bool:
if not lib_path.is_file():
continue
try:
- ctypes.CDLL(str(lib_path))
+ _cdll_load(str(lib_path))
logger.info("Loaded Codec2 native library from %s", lib_path)
return True
except OSError as exc:
@@ -106,3 +118,10 @@ def probe_pycodec2() -> tuple[bool, str | None]:
def codec2_preload_error() -> str | None:
"""Return the last preload failure message, if any."""
return _codec2_preload_error
+
+
+def reset_codec2_preload_state_for_tests() -> None:
+ """Clear preload memoization (tests only)."""
+ global _codec2_preload_done, _codec2_preload_error
+ _codec2_preload_done = False
+ _codec2_preload_error = None
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index b53e73af..7db40ffc 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -1394,10 +1394,14 @@ class ReticulumMeshChat:
ensure_safe_reticulum_runtime_flags(config_path)
def _set_startup_stage(self, stage: str, error: str | None = None) -> None:
+ previous = getattr(self, "_startup_stage", None)
self._startup_stage = stage
if error is not None:
self._startup_error = error
- print(f"Startup stage: {stage}", flush=True)
+ # Same stage can be set from both the network-setup wrapper and
+ # setup_identity. Only log transitions to keep console noise down.
+ if previous != stage or error is not None:
+ print(f"Startup stage: {stage}", flush=True)
def _mark_network_ready(self) -> None:
self._network_ready = True
@@ -1533,7 +1537,6 @@ class ReticulumMeshChat:
self._set_startup_stage("failed", "No identity available for network setup")
return
try:
- self._set_startup_stage("rns")
self.setup_identity(identity)
if self.config is not None and getattr(self, "session_secret_key", None):
try:
@@ -1603,7 +1606,6 @@ class ReticulumMeshChat:
loglevel=rns_loglevel,
)
_restore_rns_console_logging_after_reticulum_init(self)
- self._set_startup_stage("identity")
self.page_node_manager.load_nodes()
self.page_node_manager.start_all()
self.plugin_manager.set_app(self)
@@ -10572,10 +10574,13 @@ class ReticulumMeshChat:
int(profile_id),
)
self.config.telephone_audio_profile_id.set(resolved)
+ requested = int(profile_id)
return web.json_response(
{
"message": f"Switched to profile {resolved}",
"profile_id": resolved,
+ "requested_profile_id": requested,
+ "remapped": requested != resolved,
},
)
except Exception as e:
@@ -10586,17 +10591,25 @@ class ReticulumMeshChat:
async def telephone_codec2_status(request):
from meshchatx import android_codec2
- available = await asyncio.to_thread(
- self.telephone_manager.codec2_available,
- )
- return web.json_response(
- {
+ def _status():
+ probe_ok, probe_error = android_codec2.probe_pycodec2()
+ lxst_ok = self.telephone_manager.codec2_available()
+ available = bool(probe_ok and lxst_ok)
+ return {
"codec2_available": available,
"preload_error": android_codec2.codec2_preload_error(),
+ "probe_error": None if probe_ok else probe_error,
+ "platform": (
+ "android"
+ if android_codec2._is_chaquopy_android()
+ else "desktop"
+ ),
"preferred_profile_id": self.telephone_manager.preferred_profile_id,
"resolved_profile_id": self.telephone_manager.resolve_audio_profile_id(),
- },
- )
+ }
+
+ payload = await asyncio.to_thread(_status)
+ return web.json_response(payload)
# initiate a telephone call
# initiate outgoing telephone call
@@ -10664,22 +10677,37 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/audio-profiles")
async def telephone_audio_profiles(request):
from LXST.Primitives.Telephony import Profiles
+ from meshchatx import android_codec2
- # get audio profiles
- audio_profiles = [
- {
- "id": available_profile,
- "name": Profiles.profile_name(available_profile),
+ def _profiles():
+ probe_ok, _probe_err = android_codec2.probe_pycodec2()
+ codec2_ok = bool(probe_ok and self.telephone_manager.codec2_available())
+ codec2_ids = {
+ Profiles.BANDWIDTH_ULTRA_LOW,
+ Profiles.BANDWIDTH_VERY_LOW,
+ Profiles.BANDWIDTH_LOW,
}
- for available_profile in Profiles.available_profiles()
- ]
-
- return web.json_response(
- {
- "default_audio_profile_id": Profiles.DEFAULT_PROFILE,
+ audio_profiles = []
+ for profile_id in Profiles.available_profiles():
+ entry = {
+ "id": profile_id,
+ "name": Profiles.profile_name(profile_id),
+ "available": True,
+ }
+ if profile_id in codec2_ids and not codec2_ok:
+ entry["available"] = False
+ entry["unavailable_reason"] = "codec2"
+ audio_profiles.append(entry)
+ return {
+ "default_audio_profile_id": self.telephone_manager.resolve_audio_profile_id(
+ Profiles.DEFAULT_PROFILE,
+ ),
+ "codec2_available": codec2_ok,
"audio_profiles": audio_profiles,
- },
- )
+ }
+
+ payload = await asyncio.to_thread(_profiles)
+ return web.json_response(payload)
# voicemail status
@routes.get("/api/v1/telephone/voicemail/status")
@@ -19312,6 +19340,28 @@ class ReticulumMeshChat:
)
return
+ # Known hosts (map/docs) are handled above. Relay and app
+ # deep links are frontend-routed. Anything else must not
+ # fall through to LXMF ingest.
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "lxm.ingest_uri.result",
+ "status": "error",
+ "message": (
+ f"Unknown or unsupported meshchatx link host "
+ f"'{_host or '(empty)'}'. "
+ "Supported hosts include map, docs, relay, and app."
+ ),
+ "ingest_type": "unknown_meshchatx",
+ "host": _host,
+ },
+ ),
+ ),
+ )
+ return
+
# LXMA contact sharing URI:
# lxma://<destination_hash_hex>:<public_key_hex>
if uri.lower().startswith("lxma://"):
@@ -19337,18 +19387,13 @@ class ReticulumMeshChat:
bytes.fromhex(destination_hash_hex)
raw_bytes = bytes.fromhex(public_key_hex)
- identity = RNS.Identity(create_keys=False)
- loaded = False
- for candidate in (
- raw_bytes,
- raw_bytes[:32] if len(raw_bytes) > 32 else None,
- ):
- if not candidate:
- continue
- if identity.load_public_key(candidate):
- loaded = True
- break
- if not loaded:
+ # RNS Identity.load_public_key docs say True/False but the
+ # implementation returns None on success. Prefer full 64-byte
+ # keys; truncated 32-byte material is not a valid RNS pubkey.
+ identity = self._identity_from_public_key_bytes(raw_bytes)
+ if identity is None and len(raw_bytes) > 32:
+ identity = self._identity_from_public_key_bytes(raw_bytes[:32])
+ if identity is None:
raise ValueError("Invalid LXMA public key")
remote_identity_hash = identity.hash.hex()
@@ -19369,6 +19414,20 @@ class ReticulumMeshChat:
lxmf_address=destination_hash_hex,
)
+ # Persist pubkey so outbound LXMF works before any announce.
+ try:
+ RNS.Identity.remember(
+ None,
+ bytes.fromhex(destination_hash_hex),
+ identity.get_public_key(),
+ None,
+ )
+ except Exception as remember_exc:
+ print(
+ f"LXMA remember failed for {destination_hash_hex}: "
+ f"{type(remember_exc).__name__}: {remember_exc!r}",
+ )
+
AsyncUtils.run_async(
client.send_str(
json.dumps(
@@ -20368,8 +20427,8 @@ class ReticulumMeshChat:
if announce and announce.get("identity_public_key"):
public_key = base64.b64decode(announce["identity_public_key"])
- identity = RNS.Identity(create_keys=False)
- if identity.load_public_key(public_key):
+ identity = self._identity_from_public_key_bytes(public_key)
+ if identity is not None:
return identity
except Exception as e:
@@ -20377,6 +20436,27 @@ class ReticulumMeshChat:
return None
+ @staticmethod
+ def _identity_from_public_key_bytes(public_key: bytes) -> RNS.Identity | None:
+ """Load an RNS Identity from raw public-key bytes.
+
+ ``Identity.load_public_key`` is documented as returning True/False, but
+ current RNS releases return ``None`` on both success and failure. Treat
+ a non-None ``identity.pub`` (and a computed hash) as success.
+ """
+ if not public_key:
+ return None
+ identity = RNS.Identity(create_keys=False)
+ try:
+ identity.load_public_key(public_key)
+ except Exception:
+ return None
+ if getattr(identity, "pub", None) is None:
+ return None
+ if not getattr(identity, "hash", None):
+ return None
+ return identity
+
# convert an lxmf message to a dictionary, for sending over websocket
# convert database announce to a dictionary
@@ -20546,9 +20626,7 @@ class ReticulumMeshChat:
if not identity and announce.get("identity_public_key"):
# Try to load from public key if recall failed
public_key = base64.b64decode(announce["identity_public_key"])
- identity = RNS.Identity(create_keys=False)
- if not identity.load_public_key(public_key):
- identity = None
+ identity = self._identity_from_public_key_bytes(public_key)
if identity:
try:
diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py
index b1dfd813..4ab7739b 100644
--- a/meshchatx/src/backend/telephone_manager.py
+++ b/meshchatx/src/backend/telephone_manager.py
@@ -98,6 +98,15 @@ class TelephoneManager:
@staticmethod
def codec2_available() -> bool:
"""Return whether LXST can construct Codec2 codecs (pycodec2 + libcodec2)."""
+ try:
+ from meshchatx import android_codec2
+
+ if android_codec2._is_chaquopy_android():
+ ok, _err = android_codec2.probe_pycodec2()
+ if not ok:
+ return False
+ except Exception:
+ pass
try:
from LXST.Codecs import Codec2
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index bcf225ff..b31a6a56 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -91,7 +91,6 @@
/>
</button>
<LanguageSelector class="hidden sm:block" @language-change="onLanguageChange" />
- <NotificationBell />
<button
type="button"
class="sm:hidden rounded-full p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors relative"
@@ -101,7 +100,7 @@
<MaterialDesignIcon icon-name="message-text" class="w-5 h-5" />
<span
v-if="unreadConversationsCount > 0"
- class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center leading-none"
+ class="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
>
{{ unreadConversationsCount > 99 ? "99+" : unreadConversationsCount }}
</span>
@@ -116,7 +115,7 @@
<MaterialDesignIcon icon-name="forum" class="w-5 h-5" />
<span
v-if="relayChatUnreadCount > 0"
- class="absolute -top-0.5 -right-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
+ class="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
>
{{ relayChatUnreadCount > 99 ? "99+" : relayChatUnreadCount }}
</span>
@@ -238,10 +237,22 @@
<li v-for="item in visibleNavItems" :key="item.id">
<SidebarLink :to="item.route" :is-collapsed="isSidebarCollapsed">
<template #icon>
- <MaterialDesignIcon
- :icon-name="item.icon"
- class="w-6 h-6 text-gray-700 dark:text-white"
- />
+ <span class="relative inline-flex shrink-0">
+ <MaterialDesignIcon
+ :icon-name="item.icon"
+ class="w-6 h-6 text-gray-700 dark:text-white"
+ />
+ <span
+ v-if="
+ isSidebarCollapsed &&
+ getNavBadgeCount(item) > 0 &&
+ item.badge?.pill
+ "
+ class="absolute -right-2 -top-2 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
+ >
+ {{ formatNavBadgeCount(item) }}
+ </span>
+ </span>
</template>
<template #text>
<span>{{ item.label || $t(item.labelKey) }}</span>
@@ -252,8 +263,12 @@
{{ getNavBadgeCount(item) }}
</span>
<span
- v-else-if="getNavBadgeCount(item) > 0 && item.badge?.pill"
- class="ml-auto mr-2 min-w-[1.25rem] rounded-full bg-red-500 px-1.5 py-0.5 text-center text-xs font-bold text-white"
+ v-else-if="
+ !isSidebarCollapsed &&
+ getNavBadgeCount(item) > 0 &&
+ item.badge?.pill
+ "
+ class="ml-auto mr-2 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white"
>
{{ formatNavBadgeCount(item) }}
</span>
@@ -592,7 +607,6 @@ import PromptDialog from "./PromptDialog.vue";
import ToastUtils from "../js/ToastUtils";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import QRCode from "qrcode";
-import NotificationBell from "./NotificationBell.vue";
import LanguageSelector from "./LanguageSelector.vue";
import CallOverlay from "./call/CallOverlay.vue";
import CommandPalette from "./CommandPalette.vue";
@@ -626,7 +640,6 @@ export default {
ConfirmDialog,
PromptDialog,
MaterialDesignIcon,
- NotificationBell,
LanguageSelector,
CallOverlay,
CommandPalette,
@@ -957,6 +970,7 @@ export default {
GlobalEmitter.on("block-status-changed", this.onBlockStatusChangedShell);
GlobalEmitter.on("show-changelog", this.onShowChangelogShell);
GlobalEmitter.on("show-tutorial", this.onShowTutorialShell);
+ GlobalEmitter.on("notifications-changed", this.updateUnreadConversationsCount);
this.getAppInfo();
this.getConfig();
@@ -1031,6 +1045,7 @@ export default {
GlobalEmitter.off("block-status-changed", this.onBlockStatusChangedShell);
GlobalEmitter.off("show-changelog", this.onShowChangelogShell);
GlobalEmitter.off("show-tutorial", this.onShowTutorialShell);
+ GlobalEmitter.off("notifications-changed", this.updateUnreadConversationsCount);
this.clearWsShellUiTimers();
this.wsDisconnected = false;
this.wsDisconnectedAt = null;
@@ -1421,6 +1436,11 @@ export default {
this.toneGenerator.stop();
NotificationUtils.cancelIncomingCallNotification();
this.updateTelephoneStatus();
+ // Ensure CallPage is mounted so Android native audio / web
+ // audio can attach after answer from overlay or notification.
+ if (this.$route?.name !== "call" || this.$route?.query?.tab !== "phone") {
+ this.$router.push({ name: "call", query: { tab: "phone" } });
+ }
},
telephone_call_ended: () => {
this.stopRingtone();
@@ -2122,6 +2142,19 @@ export default {
this.openRelayShareLink(normalizedUrl);
return;
}
+ if (/^(meshchatx|meshchat):\/\//i.test(normalizedUrl)) {
+ try {
+ const u = new URL(normalizedUrl);
+ const host = (u.hostname || "").toLowerCase();
+ if (host && !["map", "docs", "relay", "app"].includes(host)) {
+ ToastUtils.error(this.$t("messages.unknown_meshchatx_link", { host }));
+ return;
+ }
+ } catch {
+ ToastUtils.error(this.$t("messages.unknown_meshchatx_link_generic"));
+ return;
+ }
+ }
if (/^lxm(a|f)?:\/\//i.test(normalizedUrl)) {
WebSocketConnection.send(
JSON.stringify({
diff --git a/meshchatx/src/frontend/components/NotificationBell.vue b/meshchatx/src/frontend/components/NotificationBell.vue
deleted file mode 100644
index 24c44072..00000000
--- a/meshchatx/src/frontend/components/NotificationBell.vue
+++ /dev/null
@@ -1,542 +0,0 @@
-<!-- SPDX-License-Identifier: 0BSD -->
-
-<template>
- <div class="relative">
- <button
- type="button"
- class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
- @click.stop="toggleDropdown"
- >
- <MaterialDesignIcon icon-name="bell" class="w-5 h-5 sm:w-6 sm:h-6" />
- <span
- v-if="unreadCount > 0"
- class="absolute top-0 right-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-semibold text-white"
- >
- {{ unreadCount > 9 ? "9+" : unreadCount }}
- </span>
- </button>
-
- <Teleport to="body">
- <div
- v-if="isDropdownOpen"
- ref="notificationDropdown"
- v-click-outside="closeDropdown"
- class="fixed w-80 sm:w-96 md:max-lg:w-80 lg:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-9999 max-h-[min(500px,calc(100vh-2rem))] overflow-hidden flex flex-col"
- :style="dropdownStyle"
- >
- <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
- <div class="flex items-center justify-between">
- <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
- <div class="flex items-center gap-2">
- <button
- v-if="notifications.length > 0 && !showHistory"
- type="button"
- class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
- @click.stop="clearAllNotifications"
- >
- Clear
- </button>
- <button
- type="button"
- class="rounded-md p-1 transition-colors"
- :class="
- showHistory
- ? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/40'
- : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800'
- "
- :title="$t('app.notifications_history_title')"
- :aria-label="$t('app.notifications_history_title')"
- @click.stop="toggleHistory"
- >
- <MaterialDesignIcon icon-name="history" class="w-5 h-5" />
- </button>
- <button
- type="button"
- class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
- @click="closeDropdown"
- >
- <MaterialDesignIcon icon-name="close" class="w-5 h-5" />
- </button>
- </div>
- </div>
- </div>
-
- <div class="overflow-y-auto flex-1">
- <div v-if="isLoading" class="p-8 text-center">
- <div class="inline-block animate-spin text-gray-400">
- <MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
- </div>
- <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
- </div>
-
- <div v-else-if="notifications.length === 0" class="p-8 text-center">
- <MaterialDesignIcon
- icon-name="bell-off"
- class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
- />
- <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
- {{ showHistory ? $t("app.notifications_empty_history") : $t("app.notifications_no_new") }}
- </div>
- </div>
-
- <div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
- <button
- v-for="notification in notifications"
- :key="notification.id || notification.destination_hash"
- type="button"
- class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
- @click="onNotificationClick(notification)"
- >
- <div class="flex gap-3">
- <div class="shrink-0">
- <div
- v-if="notification.lxmf_user_icon"
- class="p-2 rounded-lg"
- :style="{
- color: notification.lxmf_user_icon.foreground_colour,
- 'background-color': notification.lxmf_user_icon.background_colour,
- }"
- >
- <MaterialDesignIcon
- :icon-name="notification.lxmf_user_icon.icon_name"
- class="w-6 h-6"
- />
- </div>
- <div
- v-else-if="notification.type === 'rrc_mention'"
- class="bg-indigo-100 dark:bg-indigo-950/50 text-indigo-600 dark:text-indigo-400 p-2 rounded-lg"
- >
- <MaterialDesignIcon icon-name="forum-outline" class="w-6 h-6" />
- </div>
- <div
- v-else
- class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
- >
- <MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
- </div>
- </div>
- <div class="flex-1 min-w-0">
- <div class="flex items-start justify-between gap-2 mb-1">
- <div
- class="font-semibold text-gray-900 dark:text-white truncate"
- :title="
- notification.title ??
- notification.custom_display_name ??
- notification.display_name
- "
- >
- {{
- notification.title ??
- notification.custom_display_name ??
- notification.display_name
- }}
- </div>
- <div
- class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap shrink-0"
- >
- {{ formatTimeAgo(notification.updated_at) }}
- </div>
- </div>
- <div
- class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
- :title="
- notification.latest_message_preview ?? notification.content ?? 'No preview'
- "
- >
- {{
- notification.latest_message_preview ?? notification.content ?? "No preview"
- }}
- </div>
- </div>
- </div>
- </button>
- </div>
- </div>
- </div>
- </Teleport>
- </div>
-</template>
-
-<script>
-import MaterialDesignIcon from "./MaterialDesignIcon.vue";
-import Utils from "../js/Utils";
-import WebSocketConnection from "../js/WebSocketConnection";
-import GlobalState from "../js/GlobalState";
-import GlobalEmitter from "../js/GlobalEmitter";
-import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
-
-export default {
- name: "NotificationBell",
- components: {
- MaterialDesignIcon,
- },
- directives: {
- "click-outside": {
- mounted(el, binding) {
- el.clickOutsideEvent = function (event) {
- if (!(el === event.target || el.contains(event.target))) {
- binding.value();
- }
- };
- document.addEventListener("click", el.clickOutsideEvent);
- },
- unmounted(el) {
- document.removeEventListener("click", el.clickOutsideEvent);
- },
- },
- },
- emits: ["notifications-cleared"],
- data() {
- return {
- isDropdownOpen: false,
- isLoading: false,
- notifications: [],
- unreadCount: 0,
- reloadInterval: null,
- dropdownPosition: { top: 0, left: 0 },
- dropdownMaxHeight: null,
- showHistory: false,
- };
- },
- computed: {
- dropdownStyle() {
- const style = {
- top: `${this.dropdownPosition.top}px`,
- left: `${this.dropdownPosition.left}px`,
- };
- if (this.dropdownMaxHeight != null) {
- style.maxHeight = `${this.dropdownMaxHeight}px`;
- }
- return style;
- },
- },
- beforeUnmount() {
- if (this.reloadInterval) {
- clearInterval(this.reloadInterval);
- }
- WebSocketConnection.off("message", this.onWebsocketMessage);
- GlobalEmitter.off("notifications-changed", this.onNotificationsChanged);
- },
- mounted() {
- this.loadNotifications();
- WebSocketConnection.on("message", this.onWebsocketMessage);
- GlobalEmitter.on("notifications-changed", this.onNotificationsChanged);
- this.reloadInterval = setInterval(() => {
- this.loadNotifications({ updateList: this.isDropdownOpen });
- }, 5000);
- },
- methods: {
- shouldFetchNotifications() {
- if (!GlobalState.authSessionResolved) {
- return false;
- }
- if (!GlobalState.authEnabled) {
- return true;
- }
- return GlobalState.authenticated;
- },
- onNotificationsChanged() {
- if (!this.shouldFetchNotifications()) {
- return;
- }
- this.loadNotifications({ updateList: this.isDropdownOpen });
- },
- async toggleDropdown(event) {
- this.isDropdownOpen = !this.isDropdownOpen;
- if (this.isDropdownOpen) {
- this.showHistory = false;
- this.updateDropdownPosition(event);
- await this.loadNotifications();
- const hadNotifications = this.notifications.length > 0;
- await this.markNotificationsAsViewed();
- if (hadNotifications) {
- await this.loadNotifications({ updateList: false });
- }
- await this.$nextTick();
- this.clampNotificationDropdown();
- }
- },
- updateDropdownPosition(event) {
- const button = event.currentTarget;
- const rect = button.getBoundingClientRect();
- const isMobile = window.innerWidth < 640;
- const dropdownWidth = isMobile ? 320 : 384;
-
- this.dropdownMaxHeight = null;
- this.dropdownPosition = {
- top: rect.bottom + 8,
- left: Math.max(16, rect.right - dropdownWidth),
- };
- this.$nextTick(() => this.clampNotificationDropdown());
- },
- clampNotificationDropdown() {
- const panel = this.$refs.notificationDropdown;
- if (!panel || !this.isDropdownOpen) return;
- const pr = panel.getBoundingClientRect();
- const { left, top, maxHeight } = clampFloatingToViewport(pr.left, pr.top, pr.width, pr.height);
- this.dropdownPosition = { top, left };
- this.dropdownMaxHeight = maxHeight;
- },
- closeDropdown() {
- this.isDropdownOpen = false;
- this.showHistory = false;
- },
- async toggleHistory() {
- this.showHistory = !this.showHistory;
- await this.loadNotifications();
- if (!this.showHistory) {
- const hadNotifications = this.notifications.length > 0;
- await this.markNotificationsAsViewed();
- if (hadNotifications) {
- await this.loadNotifications({ updateList: false });
- }
- }
- if (this.isDropdownOpen) {
- await this.$nextTick();
- this.clampNotificationDropdown();
- }
- },
- async loadNotifications(options = {}) {
- const updateList = options.updateList !== false;
- if (!this.shouldFetchNotifications()) {
- this.notifications = [];
- this.unreadCount = 0;
- this.isLoading = false;
- return;
- }
- if (updateList) {
- this.isLoading = true;
- }
- try {
- const response = await window.api.get(`/api/v1/notifications`, {
- params: {
- unread: !this.showHistory,
- limit: 10,
- },
- });
- const newNotifications = response.data.notifications || [];
- if (updateList) {
- this.notifications = newNotifications;
- }
- this.unreadCount = response.data.unread_count || 0;
- } catch (e) {
- console.error("Failed to load notifications", e);
- if (updateList) {
- this.notifications = [];
- }
- } finally {
- if (updateList) {
- this.isLoading = false;
- }
- }
- },
- async markNotificationsAsViewed() {
- if (!this.shouldFetchNotifications()) {
- return;
- }
- if (this.notifications.length === 0) {
- return;
- }
- try {
- const destination_hashes = this.notifications
- .filter((n) => n.type === "lxmf_message")
- .map((n) => n.destination_hash);
- const notification_ids = this.notifications.filter((n) => n.type !== "lxmf_message").map((n) => n.id);
-
- await window.api.post("/api/v1/notifications/mark-as-viewed", {
- destination_hashes: destination_hashes,
- notification_ids: notification_ids,
- });
- } catch (e) {
- console.error("Failed to mark notifications as viewed", e);
- }
- },
- async clearAllNotifications() {
- if (!this.shouldFetchNotifications()) {
- return;
- }
- try {
- await window.api.post("/api/v1/notifications/mark-as-viewed", {
- destination_hashes: [],
- notification_ids: [],
- });
-
- const response = await window.api.get("/api/v1/lxmf/conversations");
- const conversations = response.data.conversations || [];
-
- for (const conversation of conversations) {
- if (conversation.is_unread) {
- try {
- await window.api.post(
- `/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`
- );
- } catch (e) {
- console.error(`Failed to mark conversation as read: ${conversation.destination_hash}`, e);
- }
- }
- }
-
- GlobalState.unreadConversationsCount = 0;
-
- this.showHistory = false;
- await this.loadNotifications();
- this.$emit("notifications-cleared");
- } catch (e) {
- console.error("Failed to clear notifications", e);
- }
- },
- async onNotificationClick(notification) {
- this.closeDropdown();
-
- if (!this.shouldFetchNotifications()) {
- return;
- }
-
- // Mark this specific notification as viewed
- try {
- const destination_hashes = notification.type === "lxmf_message" ? [notification.destination_hash] : [];
- const notification_ids = notification.type !== "lxmf_message" ? [notification.id] : [];
-
- await window.api.post("/api/v1/notifications/mark-as-viewed", {
- destination_hashes: destination_hashes,
- notification_ids: notification_ids,
- });
-
- // reload to update unread count
- await this.loadNotifications();
- } catch (e) {
- console.error("Failed to mark notification as viewed", e);
- }
-
- if (notification.type === "lxmf_message") {
- this.$router.push({
- name: "messages",
- params: { destinationHash: notification.destination_hash },
- });
- } else if (notification.type === "telephone_missed_call") {
- this.$router.push({
- name: "call",
- query: { tab: "history" },
- });
- } else if (notification.type === "telephone_voicemail") {
- this.$router.push({
- name: "call",
- query: { tab: "voicemail" },
- });
- } else if (notification.type === "rrc_mention") {
- const remote = notification.destination_hash || "";
- const sep = remote.indexOf(":");
- if (sep > 0) {
- const hub = remote.slice(0, sep);
- const room = decodeURIComponent(remote.slice(sep + 1));
- try {
- await window.api.post(`/api/v1/rrc/hubs/${hub}/rooms/${encodeURIComponent(room)}/read`);
- } catch (e) {
- console.error("Failed to mark relay room read", e);
- }
- this.$router.push({
- name: "relay-chat",
- query: { hub, room },
- });
- } else {
- this.$router.push({ name: "relay-chat" });
- }
- }
- },
- formatTimeAgo(datetimeString) {
- return Utils.formatTimeAgo(datetimeString);
- },
- isUserFacingLxmfDelivery(lxmfMessage) {
- if (!lxmfMessage || lxmfMessage.is_incoming !== true) {
- return false;
- }
- // Reactions never count as a notification.
- if (lxmfMessage.is_reaction === true) {
- return false;
- }
- const fields = lxmfMessage.fields || {};
- const reaction = fields.reaction;
- if (
- reaction &&
- typeof reaction === "object" &&
- Object.prototype.hasOwnProperty.call(reaction, "reaction_to")
- ) {
- return false;
- }
- const content = (lxmfMessage.content || "").toString().trim();
- const title = (lxmfMessage.title || "").toString().trim();
- if (content.length > 0 || title.length > 0) {
- return true;
- }
- const image = fields.image;
- if (image && (image.image_size || image.image_bytes)) {
- return true;
- }
- const audio = fields.audio;
- if (audio && (audio.audio_size || audio.audio_bytes)) {
- return true;
- }
- const fileAttachments = fields.file_attachments;
- if (Array.isArray(fileAttachments) && fileAttachments.length > 0) {
- return true;
- }
- return false;
- },
- async onWebsocketMessage(message) {
- if (!this.shouldFetchNotifications()) {
- return;
- }
- let json;
- try {
- json = JSON.parse(message.data);
- } catch {
- return;
- }
- if (json.type === "lxmf.delivery") {
- if (!this.isUserFacingLxmfDelivery(json.lxmf_message)) {
- return;
- }
- await this.loadNotifications();
- if (this.isDropdownOpen) {
- const hadNotifications = this.notifications.length > 0;
- await this.markNotificationsAsViewed();
- if (hadNotifications) {
- await this.loadNotifications({ updateList: false });
- }
- }
- return;
- }
- if (json.type === "telephone_missed_call" || json.type === "new_voicemail") {
- await this.loadNotifications();
- if (this.isDropdownOpen) {
- const hadNotifications = this.notifications.length > 0;
- await this.markNotificationsAsViewed();
- if (hadNotifications) {
- await this.loadNotifications({ updateList: false });
- }
- }
- return;
- }
- if (json.type === "rrc.message" && (json.mention || json.message?.mention)) {
- await this.loadNotifications();
- if (this.isDropdownOpen) {
- const hadNotifications = this.notifications.length > 0;
- await this.markNotificationsAsViewed();
- if (hadNotifications) {
- await this.loadNotifications({ updateList: false });
- }
- }
- }
- },
- },
-};
-</script>
-
-<style scoped>
-.line-clamp-2 {
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-</style>
diff --git a/meshchatx/src/frontend/components/SidebarLink.vue b/meshchatx/src/frontend/components/SidebarLink.vue
index 81e93bd7..425fbefc 100644
--- a/meshchatx/src/frontend/components/SidebarLink.vue
+++ b/meshchatx/src/frontend/components/SidebarLink.vue
@@ -9,8 +9,9 @@
isActive
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-zinc-700',
+ isCollapsed ? 'overflow-visible' : 'overflow-hidden',
]"
- class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500 overflow-hidden"
+ class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"
@click="handleNavigate($event, navigate)"
>
<span class="my-auto shrink-0">
diff --git a/meshchatx/src/frontend/components/call/CallOverlay.vue b/meshchatx/src/frontend/components/call/CallOverlay.vue
index 54636006..b82df1fa 100644
--- a/meshchatx/src/frontend/components/call/CallOverlay.vue
+++ b/meshchatx/src/frontend/components/call/CallOverlay.vue
@@ -419,6 +419,12 @@ export default {
async answerCall() {
try {
await window.api.get("/api/v1/telephone/answer");
+ // Native Android audio (and desktop web-audio) only attach from
+ // CallPage. Overlay accept must open the phone tab or the call
+ // stays silent after answer.
+ if (this.$route?.name !== "call" || this.$route?.query?.tab !== "phone") {
+ await this.$router.push({ name: "call", query: { tab: "phone" } });
+ }
} catch {
ToastUtils.error(this.$t("call.failed_to_answer_call"));
}
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 93c56374..8bed093e 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2903,13 +2903,18 @@ export default {
async disableWebAudioBridgeWithError(errorKey, error, stage = "unknown") {
this.logWebAudioFailure(stage, error);
ToastUtils.error(this.$t(errorKey));
- if (this.config) {
- this.config.telephone_web_audio_enabled = false;
- }
- try {
- await this.updateConfig({ telephone_web_audio_enabled: false });
- } catch (updateError) {
- this.logWebAudioFailure("disable-config-update", updateError);
+ // On Android the backend forces web_audio.enabled while Chaquopy is
+ // present. Permanently clearing the config flag just creates a 1s
+ // retry/toast loop without helping recovery.
+ if (!this.isMeshChatXAndroid()) {
+ if (this.config) {
+ this.config.telephone_web_audio_enabled = false;
+ }
+ try {
+ await this.updateConfig({ telephone_web_audio_enabled: false });
+ } catch (updateError) {
+ this.logWebAudioFailure("disable-config-update", updateError);
+ }
}
this.stopWebAudio();
},
@@ -3509,8 +3514,15 @@ export default {
async getAudioProfiles() {
try {
const response = await window.api.get("/api/v1/telephone/audio-profiles");
- this.audioProfiles = response.data.audio_profiles;
+ const profiles = Array.isArray(response.data.audio_profiles) ? response.data.audio_profiles : [];
+ this.audioProfiles = profiles.filter((p) => p && p.available !== false);
this.selectedAudioProfileId = response.data.default_audio_profile_id;
+ if (response.data.codec2_available === false) {
+ const hadCodec2 = profiles.some((p) => p && p.unavailable_reason === "codec2");
+ if (hadCodec2) {
+ ToastUtils.warning(this.$t("call.codec2_unavailable"));
+ }
+ }
} catch (e) {
console.log(e);
}
@@ -4354,7 +4366,14 @@ export default {
},
async switchAudioProfile(audioProfileId) {
try {
- await window.api.get(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
+ const response = await window.api.get(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
+ const resolved = response.data?.profile_id;
+ if (resolved != null) {
+ this.selectedAudioProfileId = resolved;
+ }
+ if (response.data?.remapped) {
+ ToastUtils.warning(this.$t("call.codec2_profile_remapped"));
+ }
} catch {
ToastUtils.error(this.$t("call.failed_to_switch_audio_profile"));
}
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 8b598550..f29a7503 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -3611,8 +3611,11 @@ export default {
});
const conversation = this.findConversation(this.selectedPeer.destination_hash);
- if (conversation) {
- this.markConversationAsRead(conversation);
+ const target = conversation || this.selectedPeer;
+ if (target) {
+ // Force mark even when local is_unread is still false. Delivery bumps the
+ // nav badge from the server before the conversation list refreshes.
+ this.markConversationAsRead(target, { force: true });
}
if (this.autoScrollOnNewMessage) {
@@ -7151,9 +7154,13 @@ export default {
return conversation.destination_hash === destinationHash;
});
},
- async markConversationAsRead(conversation) {
+ async markConversationAsRead(conversation, { force = false } = {}) {
+ if (!conversation?.destination_hash) {
+ return;
+ }
+
const wasUnread = conversation.is_unread === true;
- if (!wasUnread) {
+ if (!wasUnread && !force) {
return;
}
@@ -7163,11 +7170,11 @@ export default {
try {
await window.api.post(`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`);
GlobalEmitter.emit("notifications-changed");
- if (GlobalState.unreadConversationsCount > 0) {
+ if (wasUnread && GlobalState.unreadConversationsCount > 0) {
GlobalState.unreadConversationsCount -= 1;
}
} catch (e) {
- conversation.is_unread = true;
+ conversation.is_unread = wasUnread;
console.log(e);
}
},
diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index 152ec788..f6798f27 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -209,6 +209,61 @@ export default class MicronParser extends BaseMicronParser {
}
`;
document.head.appendChild(styleEl);
+ MicronParser.installMicronCopyFix();
+ }
+
+ /**
+ * Browsers insert newlines between adjacent ``inline-block`` Mu-mnt cells
+ * when copying. Rebuild clipboard text without those spurious breaks while
+ * keeping intentional block-level line breaks.
+ */
+ static installMicronCopyFix() {
+ if (typeof document === "undefined" || window.__meshchatxMicronCopyFix) {
+ return;
+ }
+ window.__meshchatxMicronCopyFix = true;
+ document.addEventListener("copy", (event) => {
+ const sel = window.getSelection();
+ if (!sel || sel.isCollapsed || !sel.rangeCount) {
+ return;
+ }
+ const anchor = sel.anchorNode;
+ const focus = sel.focusNode;
+ const anchorEl = anchor && anchor.nodeType === Node.ELEMENT_NODE ? anchor : anchor?.parentElement;
+ const focusEl = focus && focus.nodeType === Node.ELEMENT_NODE ? focus : focus?.parentElement;
+ const inMicron = (el) => Boolean(el?.closest?.(".Mu-mws, .Mu-mnt, .Mu-mnt-full, .Mu-mnt-group, .Mu-nl"));
+ if (!inMicron(anchorEl) && !inMicron(focusEl)) {
+ return;
+ }
+ try {
+ const range = sel.getRangeAt(0);
+ const fragment = range.cloneContents();
+ const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_ALL);
+ let out = "";
+ let node = walker.nextNode();
+ while (node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ out += node.nodeValue || "";
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
+ const tag = node.tagName;
+ if (tag === "BR" || tag === "DIV" || tag === "P" || tag === "PRE") {
+ if (out.length && !out.endsWith("\n")) {
+ out += "\n";
+ }
+ }
+ }
+ node = walker.nextNode();
+ }
+ // Collapse newlines that came only from adjacent inline-block cells.
+ const cleaned = out.replace(/([^\n])\n(?!\n)/g, "$1");
+ if (cleaned && event.clipboardData) {
+ event.clipboardData.setData("text/plain", cleaned);
+ event.preventDefault();
+ }
+ } catch {
+ /* leave default copy behaviour */
+ }
+ });
}
convertMicronToHtmlWasmHybrid(markup, partialContents = {}) {
diff --git a/meshchatx/src/frontend/js/registries/coreNavEntries.js b/meshchatx/src/frontend/js/registries/coreNavEntries.js
index 207b3598..3d8d5c02 100644
--- a/meshchatx/src/frontend/js/registries/coreNavEntries.js
+++ b/meshchatx/src/frontend/js/registries/coreNavEntries.js
@@ -21,7 +21,7 @@ export const CORE_NAV_ENTRIES = [
route: { name: "messages" },
icon: "message-text",
labelKey: "app.messages",
- badge: { source: "unreadConversationsCount" },
+ badge: { source: "unreadConversationsCount", pill: true, cap: 99 },
},
{
id: "call",
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 9bc4597f..785b460e 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -355,9 +355,6 @@
"announce_limit_prop": "Prop-Knoten",
"failed_announce": "Ankündigung fehlgeschlagen",
"announce_sent": "Ankündigung gesendet",
- "notifications_no_new": "Keine neuen Benachrichtigungen",
- "notifications_empty_history": "Kein Benachrichtigungsverlauf",
- "notifications_history_title": "Letzter Benachrichtigungsverlauf",
"notifications": "Benachrichtigungen",
"notification_sound_settings": "Benachrichtigungston für Nachrichten",
"notification_sound_settings_description": "Spielt einen benutzerdefinierten Ton ab, wenn Sie eine neue Nachricht erhalten, während MeshChat geöffnet ist. Laden Sie zuerst eine Audiodatei hoch und aktivieren Sie dann die Wiedergabe.",
@@ -1829,7 +1826,9 @@
"conversation_files_other": "{name} hat {count} Dateien gesendet",
"message_not_found_in_cache": "Nachricht nicht im Cache gefunden",
"failed_to_send": "Nachricht konnte nicht gesendet werden",
- "failed_to_send_image": "Bild {index} konnte nicht gesendet werden: {detail}"
+ "failed_to_send_image": "Bild {index} konnte nicht gesendet werden: {detail}",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"nomadnet": {
"remove_favourite": "Favorit entfernen",
@@ -2975,7 +2974,9 @@
"failed_to_send_to_voicemail": "Fehler beim Senden des Anrufs an Voicemail",
"failed_load_audio_edit": "Fehler beim Laden des Audios zur Bearbeitung",
"ringtone_saved": "Klingelton erfolgreich gespeichert",
- "failed_save_ringtone": "Fehler beim Speichern des bearbeiteten Klingeltons"
+ "failed_save_ringtone": "Fehler beim Speichern des bearbeiteten Klingeltons",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"tutorial": {
"title": "Erste Schritte",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 74906b99..5dc52b35 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -89,9 +89,6 @@
"show_qr": "Show QR Code",
"failed_announce": "failed to announce",
"announce_sent": "Announcement sent",
- "notifications_no_new": "No new notifications",
- "notifications_empty_history": "No notification history",
- "notifications_history_title": "Recent notification history",
"notifications": "Notifications",
"notification_sound_settings": "Message Notification Sound",
"notification_sound_settings_description": "Play a custom sound when you receive a new message while MeshChat is open. Upload a sound file first, then enable playback.",
@@ -1656,6 +1653,8 @@
"relay_link_invalid": "Invalid relay link",
"relay_link_failed": "Could not open relay link",
"relay_link_disabled": "Relay Chat is disabled in settings",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link",
"map_link_ping_title": "Map ping",
"map_link_open": "Open on map",
"map_link_copy_uri": "Copy meshchatx link",
@@ -3366,6 +3365,8 @@
"failed_to_toggle_microphone": "Failed to toggle microphone",
"failed_to_toggle_speaker": "Failed to toggle speaker",
"failed_to_switch_audio_profile": "Failed to switch audio profile",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus.",
"failed_to_initiate_call": "Failed to initiate call",
"enter_identity_hash_to_call_error": "Enter an identity to call",
"failed_to_save_settings": "Failed to save settings",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 96c8edd0..7702a022 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -88,9 +88,6 @@
"show_qr": "Mostrar código QR",
"failed_announce": "anuncio fallido",
"announce_sent": "Anuncio enviado",
- "notifications_no_new": "No hay nuevas notificaciones",
- "notifications_empty_history": "No hay historial de notificación",
- "notifications_history_title": "Historial de notificación reciente",
"notifications": "Notificaciones",
"notification_sound_settings": "Sonido de notificación de mensajes",
"notification_sound_settings_description": "Reproduce un sonido personalizado cuando recibes un mensaje nuevo mientras MeshChat está abierto. Sube primero un archivo de audio y luego activa la reproducción.",
@@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} envió {count} archivos",
"message_not_found_in_cache": "Mensaje no encontrado en caché",
"failed_to_send": "Error al enviar el mensaje",
- "failed_to_send_image": "Error al enviar la imagen {index}: {detail}"
+ "failed_to_send_image": "Error al enviar la imagen {index}: {detail}",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "Guardado a mano",
@@ -3168,7 +3167,9 @@
"failed_to_send_to_voicemail": "Error al enviar la llamada al buzón de voz",
"failed_load_audio_edit": "No se puede cargar audio para editar",
"ringtone_saved": "Ringtone se salvó con éxito",
- "failed_save_ringtone": "Error al guardar el tono editado"
+ "failed_save_ringtone": "Error al guardar el tono editado",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "Contactos",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index c48abf32..2a0da62a 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -89,9 +89,6 @@
"show_qr": "Näytä QR-koodi",
"failed_announce": "Kuulutus epäonnistui",
"announce_sent": "Kuulutus lähetetty",
- "notifications_no_new": "Ei uusia ilmoituksia",
- "notifications_empty_history": "Ei ilmoitushistoriaa",
- "notifications_history_title": "Viimeaikainen ilmoitushistoria",
"notifications": "Ilmoitukset",
"notification_sound_settings": "Viestien ilmoitusääni",
"notification_sound_settings_description": "Toistaa mukautetun äänen, kun saat uuden viestin MeshChatin ollessa auki. Lataa ensin äänitiedosto ja ota toisto käyttöön.",
@@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} lähetti {count} tiedostoa",
"message_not_found_in_cache": "Viestiä ei löytynyt välimuistista",
"failed_to_send": "Viestin lähettäminen epäonnistui",
- "failed_to_send_image": "Kuvan {index} lähettäminen epäonnistui: {detail}"
+ "failed_to_send_image": "Kuvan {index} lähettäminen epäonnistui: {detail}",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"tabs": {
@@ -3358,7 +3357,9 @@
"failed_to_send_to_voicemail": "Puhelun lähettäminen vastaajaan epäonnistui",
"failed_load_audio_edit": "Äänen lataaminen muokkausta varten epäonnistui",
"ringtone_saved": "Soittoääni tallennettu onnistuneesti",
- "failed_save_ringtone": "Muokatun soittoäänen tallentaminen epäonnistui"
+ "failed_save_ringtone": "Muokatun soittoäänen tallentaminen epäonnistui",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "Yhteystiedot",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 0757a52d..c19b2f0b 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -88,9 +88,6 @@
"show_qr": "Afficher le code QR",
"failed_announce": "a échoué à annoncer",
"announce_sent": "Avis envoyé",
- "notifications_no_new": "Aucune nouvelle notification",
- "notifications_empty_history": "Aucun historique de notification",
- "notifications_history_title": "Historique de la notification récente",
"notifications": "Notifications",
"notification_sound_settings": "Son de notification de message",
"notification_sound_settings_description": "Joue un son personnalisé lorsque vous recevez un nouveau message pendant que MeshChat est ouvert. Téléchargez d'abord un fichier audio, puis activez la lecture.",
@@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} a envoyé {count} fichiers",
"message_not_found_in_cache": "Message non trouvé dans cache",
"failed_to_send": "Échec de l'envoi du message",
- "failed_to_send_image": "Échec de l'envoi de l'image {index} : {detail}"
+ "failed_to_send_image": "Échec de l'envoi de l'image {index} : {detail}",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "Raccourci enregistré",
@@ -3168,7 +3167,9 @@
"failed_to_send_to_voicemail": "Échec de l'envoi de l'appel à la messagerie vocale",
"failed_load_audio_edit": "Impossible de charger l'audio pour l'édition",
"ringtone_saved": "Sonnerie enregistrée avec succès",
- "failed_save_ringtone": "Impossible d'enregistrer la sonnerie éditée"
+ "failed_save_ringtone": "Impossible d'enregistrer la sonnerie éditée",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "Personnes-ressources",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index f37f3bf0..7e3c2a39 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -88,9 +88,6 @@
"show_qr": "Mostra Codice QR",
"failed_announce": "impossibile annunciare",
"announce_sent": "Annuncio inviato",
- "notifications_no_new": "Nessuna nuova notifica",
- "notifications_empty_history": "Nessuna cronologia notifiche",
- "notifications_history_title": "Cronologia notifiche recenti",
"notifications": "Notifiche",
"notification_sound_settings": "Suono di notifica messaggi",
"notification_sound_settings_description": "Riproduce un suono personalizzato quando ricevi un nuovo messaggio mentre MeshChat è aperto. Carica prima un file audio, poi abilita la riproduzione.",
@@ -1829,7 +1826,9 @@
"conversation_files_other": "{name} ha inviato {count} file",
"message_not_found_in_cache": "Messaggio non trovato nella cache",
"failed_to_send": "Impossibile inviare il messaggio",
- "failed_to_send_image": "Impossibile inviare l'immagine {index}: {detail}"
+ "failed_to_send_image": "Impossibile inviare l'immagine {index}: {detail}",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "Scorciatoia salvata",
@@ -3220,7 +3219,9 @@
"failed_to_send_to_voicemail": "Impossibile inviare la chiamata alla segreteria",
"failed_load_audio_edit": "Impossibile caricare l'audio per la modifica",
"ringtone_saved": "Suoneria salvata con successo",
- "failed_save_ringtone": "Impossibile salvare la suoneria modificata"
+ "failed_save_ringtone": "Impossibile salvare la suoneria modificata",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"tutorial": {
"title": "Guida Introduttiva",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 2f82f63c..e1bc9872 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -88,9 +88,6 @@
"show_qr": "QR-code tonen",
"failed_announce": "kon niet aankondigen",
"announce_sent": "Mededeling verzonden",
- "notifications_no_new": "Geen nieuwe meldingen",
- "notifications_empty_history": "Geen meldingsgeschiedenis",
- "notifications_history_title": "Recente meldingsgeschiedenis",
"notifications": "Meldingen",
"notification_sound_settings": "Meldingsgeluid voor berichten",
"notification_sound_settings_description": "Speelt een aangepast geluid af wanneer je een nieuw bericht ontvangt terwijl MeshChat open is. Upload eerst een audiobestand en schakel daarna afspelen in.",
@@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} heeft {count} bestanden gestuurd",
"message_not_found_in_cache": "Bericht niet gevonden in cache",
"failed_to_send": "Bericht verzenden mislukt",
- "failed_to_send_image": "Afbeelding {index} verzenden mislukt: {detail}"
+ "failed_to_send_image": "Afbeelding {index} verzenden mislukt: {detail}",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "Sneltoets opgeslagen",
@@ -3168,7 +3167,9 @@
"failed_to_send_to_voicemail": "Kon gesprek naar voicemail niet versturen",
"failed_load_audio_edit": "Kon audio niet laden om te bewerken",
"ringtone_saved": "Ringtone is succesvol opgeslagen",
- "failed_save_ringtone": "Opslaan van bewerkte ringtone mislukt"
+ "failed_save_ringtone": "Opslaan van bewerkte ringtone mislukt",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "Contacten",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 416fcfa7..6c5337b9 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -355,9 +355,6 @@
"announce_limit_prop": "Prop-узлы",
"failed_announce": "ошибка анонса",
"announce_sent": "Анонс отправлен",
- "notifications_no_new": "Нет новых уведомлений",
- "notifications_empty_history": "Нет истории уведомлений",
- "notifications_history_title": "Недавняя история уведомлений",
"notifications": "Уведомления",
"notification_sound_settings": "Звук уведомления о сообщении",
"notification_sound_settings_description": "Воспроизводит пользовательский звук при получении нового сообщения, пока MeshChat открыт. Сначала загрузите аудиофайл, затем включите воспроизведение.",
@@ -1829,7 +1826,9 @@
"conversation_files_other": "{name} отправил(а) файлов: {count}",
"message_not_found_in_cache": "Сообщение не найдено в кэше",
"failed_to_send": "Не удалось отправить сообщение",
- "failed_to_send_image": "Не удалось отправить изображение {index}: {detail}"
+ "failed_to_send_image": "Не удалось отправить изображение {index}: {detail}",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"nomadnet": {
"remove_favourite": "Удалить из избранного",
@@ -2975,7 +2974,9 @@
"failed_to_send_to_voicemail": "Не удалось отправить вызов на голосовую почту",
"failed_load_audio_edit": "Не удалось загрузить аудио для редактирования",
"ringtone_saved": "Рингтон успешно сохранён",
- "failed_save_ringtone": "Не удалось сохранить отредактированный рингтон"
+ "failed_save_ringtone": "Не удалось сохранить отредактированный рингтон",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"tutorial": {
"title": "Начало работы",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 59dd4174..7aed01f8 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -88,9 +88,6 @@
"show_qr": "显示二维码",
"failed_announce": "广播失败",
"announce_sent": "广播已发送",
- "notifications_no_new": "没有新通知",
- "notifications_empty_history": "无通知历史",
- "notifications_history_title": "最近通知历史",
"notifications": "通知",
"notification_sound_settings": "消息通知声音",
"notification_sound_settings_description": "在 MeshChat 打开时收到新消息播放自定义声音。请先上传音频文件,然后启用播放。",
@@ -1777,7 +1774,9 @@
"conversation_files_other": "{name} 发送了 {count} 个文件",
"message_not_found_in_cache": "缓存中未找到消息",
"failed_to_send": "发送消息失败",
- "failed_to_send_image": "发送图片 {index} 失败:{detail}"
+ "failed_to_send_image": "发送图片 {index} 失败:{detail}",
+ "unknown_meshchatx_link": "Unknown meshchatx link ({host})",
+ "unknown_meshchatx_link_generic": "Unknown or invalid meshchatx link"
},
"settings": {
"shortcut_saved": "快捷键已保存",
@@ -3168,7 +3167,9 @@
"failed_to_send_to_voicemail": "发送通话到语音信箱失败",
"failed_load_audio_edit": "加载用于编辑的音频失败",
"ringtone_saved": "铃声保存成功",
- "failed_save_ringtone": "保存编辑的铃声失败"
+ "failed_save_ringtone": "保存编辑的铃声失败",
+ "codec2_unavailable": "Codec2 is not available on this device. Low-bandwidth call profiles are hidden.",
+ "codec2_profile_remapped": "Codec2 is unavailable so the call profile was switched to Opus."
},
"contacts": {
"title": "联系人",
diff --git a/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json b/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
index 2202f63e..ac89830a 100644
--- a/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
+++ b/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
@@ -1,6 +1,6 @@
{
"version": "1.2.0",
- "wasm": "sha384-kx5+sGvflCkoO65dt5IcMJVB/8y85IAwT6zUbAKjha1XK/8p3tMp+QFwX5hd2DYN",
+ "wasm": "sha384-qIr9tHzeIj1WPDNVA/HjxTBvvKMXeIFMWdq3S6ZGXljC/VWgs1IQ9mCbjyVw7NBD",
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
"wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
}
diff --git a/scripts/build-visualiser-wasm.mjs b/scripts/build-visualiser-wasm.mjs
index 05781d3c..06993802 100644
--- a/scripts/build-visualiser-wasm.mjs
+++ b/scripts/build-visualiser-wasm.mjs
@@ -50,8 +50,17 @@ function main() {
process.exit(0);
}
+ const requireWasm =
+ process.env.MESHCHATX_REQUIRE_VISUALISER_WASM === "1" ||
+ process.env.MESHCHATX_REQUIRE_VISUALISER_WASM === "true";
+
if (!fs.existsSync(path.join(GO_MOD_DIR, "go.mod"))) {
- console.warn("build-visualiser-wasm: visualiser-wasm/go.mod missing, skipping.");
+ const msg = "build-visualiser-wasm: visualiser-wasm/go.mod missing, skipping.";
+ if (requireWasm) {
+ console.error(msg);
+ process.exit(1);
+ }
+ console.warn(msg);
process.exit(0);
}
@@ -67,7 +76,12 @@ function main() {
console.error("build-visualiser-wasm: MESHCHATX_OFFLINE_BUILD=1 but artifacts missing and go unavailable.");
process.exit(1);
}
- console.warn("build-visualiser-wasm: go not found, skipping (JS fallback will be used).");
+ const msg = "build-visualiser-wasm: go not found, skipping (JS fallback will be used).";
+ if (requireWasm) {
+ console.error(msg);
+ process.exit(1);
+ }
+ console.warn(msg);
process.exit(0);
}
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index ae3ad3df..85f48303 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -1,1560 +1,1560 @@
{
- "routes": [
- {
- "method": "GET",
- "path": "/"
- },
- {
- "method": "GET",
- "path": "/api/v1/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/announces"
- },
- {
- "method": "POST",
- "path": "/api/v1/announces/query"
- },
- {
- "method": "GET",
- "path": "/api/v1/app/changelog"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/changelog/seen"
- },
- {
- "method": "GET",
- "path": "/api/v1/app/info"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/integrity/acknowledge"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/shutdown"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/tutorial/seen"
- },
- {
- "method": "GET",
- "path": "/api/v1/auth/csrf"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/login"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/logout"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/setup"
- },
- {
- "method": "GET",
- "path": "/api/v1/auth/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/blocked-destinations"
- },
- {
- "method": "POST",
- "path": "/api/v1/blocked-destinations"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/blocked-destinations/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/announce"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/delete"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/restart"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/start"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/subprocess-log"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/bots/update"
- },
- {
- "method": "GET",
- "path": "/api/v1/community-interfaces"
- },
- {
- "method": "POST",
- "path": "/api/v1/community-interfaces/refresh"
- },
- {
- "method": "GET",
- "path": "/api/v1/comports"
- },
- {
- "method": "GET",
- "path": "/api/v1/config"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/config"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/backup"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backup/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backups"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/database/backups/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backups/{filename}/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/health"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/recover"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/restore"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/snapshot"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/snapshots"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/database/snapshots/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/snapshots/{filename}/download"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/vacuum"
- },
- {
- "method": "GET",
- "path": "/api/v1/debug/access-attempts"
- },
- {
- "method": "GET",
- "path": "/api/v1/debug/logs"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/custom-display-name"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/custom-display-name/update"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/drop-path"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/lxmf-stamp-info"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/path"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/request-path"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/signal-metrics"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/gc"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/gc/collect"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/heap"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/referrers"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/reset"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/snapshot"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/export/reticulum"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/search"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/docs/switch"
- },
- {
- "method": "POST",
- "path": "/api/v1/docs/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/docs/version/{version}"
- },
- {
- "method": "GET",
- "path": "/api/v1/favourites"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/add"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/import"
- },
- {
- "method": "GET",
- "path": "/api/v1/favourites/layout"
- },
- {
- "method": "PUT",
- "path": "/api/v1/favourites/layout"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/favourites/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/{destination_hash}/rename"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/gifs/{gif_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/gifs/{gif_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs/{gif_id}/image"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs/{gif_id}/use"
- },
- {
- "method": "GET",
- "path": "/api/v1/identities"
- },
- {
- "method": "POST",
- "path": "/api/v1/identities/create"
- },
- {
- "method": "GET",
- "path": "/api/v1/identities/export-all"
- },
- {
- "method": "POST",
- "path": "/api/v1/identities/switch"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/identities/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/identity/backup/base32"
- },
- {
- "method": "GET",
- "path": "/api/v1/identity/backup/download"
- },
- {
- "method": "POST",
- "path": "/api/v1/identity/restore"
- },
- {
- "method": "GET",
- "path": "/api/v1/interface-stats"
- },
- {
- "method": "GET",
- "path": "/api/v1/licenses"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/reactions"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/send"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf-messages/{hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/{hash}/cancel"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/{hash}/spam"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/{message_hash}/uri"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/conversation-pins"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversation-pins/toggle"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/conversations"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/bulk-delete"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/bulk-mark-as-read"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/move-to-folder"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/{destination_hash}/mark-as-read"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/folders"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/folders"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/folders/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/folders/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf/folders/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/lxmf/folders/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/message-blocklist"
- },
- {
- "method": "PUT",
- "path": "/api/v1/lxmf/message-blocklist"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/message-blocklist/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/message-blocklist/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/propagation-node/restart"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/propagation-node/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/stop-sync"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/sync"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-nodes"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/sieve-filters"
- },
- {
- "method": "PUT",
- "path": "/api/v1/lxmf/sieve-filters"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/announces"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/archives"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/docs/reticulum"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/favourites"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/gifs"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/lxmf-icons"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/messages"
- },
- {
- "method": "GET",
- "path": "/api/v1/maintenance/messages/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/maintenance/messages/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/maintenance/messages/import-file"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/path-table"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/stickers"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/drawings"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/drawings"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/drawings/{drawing_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/map/drawings/{drawing_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/export"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/export/{export_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/export/{export_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/export/{export_id}/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/mbtiles"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/mbtiles/active"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/mbtiles/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/offline"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/offline"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/overlays"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/overlays"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/overlays/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/overlays/jobs/{job_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/overlays/jobs/{job_id}/cancel"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/overlays/{overlay_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/map/overlays/{overlay_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/overlays/{overlay_id}/content"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/overlays/{overlay_id}/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/overlays/{overlay_id}/refresh"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/tiles/{z}/{x}/{y}"
- },
- {
- "method": "GET",
- "path": "/api/v1/meshchatx-docs/content"
- },
- {
- "method": "GET",
- "path": "/api/v1/meshchatx-docs/list"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/nomadnet/archives"
- },
- {
- "method": "GET",
- "path": "/api/v1/nomadnet/archives"
- },
- {
- "method": "POST",
- "path": "/api/v1/nomadnetwork/{destination_hash}/identify"
- },
- {
- "method": "GET",
- "path": "/api/v1/notification-sounds"
- },
- {
- "method": "GET",
- "path": "/api/v1/notification-sounds/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/notification-sounds/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/notification-sounds/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/notification-sounds/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/notification-sounds/{id}/audio"
- },
- {
- "method": "GET",
- "path": "/api/v1/notifications"
- },
- {
- "method": "POST",
- "path": "/api/v1/notifications/mark-as-viewed"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/files"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/files"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}/files/{file_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/pages"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/pages"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
- },
- {
- "method": "PUT",
- "path": "/api/v1/page-nodes/{node_id}/rename"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/path-table"
- },
- {
- "method": "POST",
- "path": "/api/v1/path-table"
- },
- {
- "method": "GET",
- "path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
- },
- {
- "method": "GET",
- "path": "/api/v1/plugins"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/install"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/preview"
- },
- {
- "method": "GET",
- "path": "/api/v1/plugins/trusted-publishers"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/trusted-publishers"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/plugins/trusted-publishers/{identity}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/plugins/{plugin_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/disable"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/enable"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/invoke"
- },
- {
- "method": "POST",
- "path": "/api/v1/plugins/{plugin_id}/report-failure"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/restart"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/repository-server/list"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/refresh-bundled"
- },
- {
- "method": "GET",
- "path": "/api/v1/repository-server/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/repository-server/upload/{name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/blackhole"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/config/raw"
- },
- {
- "method": "PUT",
- "path": "/api/v1/reticulum/config/raw"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/config/reset"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/disable-transport"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/discovered-interfaces"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/discovery"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/reticulum/discovery"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/enable-transport"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/instance"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/reticulum/instance"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/interface-modules"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interface-modules"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/reticulum/interface-modules/{type_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/interfaces"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/add"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/bitrates"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/delete"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/disable"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/enable"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/import-preview"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/management-identities"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/management-identities"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/recover"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/reload"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/cancel"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/fetch"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/listen"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/send"
- },
- {
- "method": "GET",
- "path": "/api/v1/rncp/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rncp/transfer/{transfer_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop-queues"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop-via"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/rates"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/request"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/table"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/trace/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnprobe"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnsh/sessions"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rnsh/sessions/{session_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/clear"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/input"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnsh/sessions/{session_id}/output"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/resize"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnstatus"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnx/sessions"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rnx/sessions/{session_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/clear"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/input"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnx/sessions/{session_id}/output"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/resize"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnx/sessions/{session_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/hubs"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs"
- },
- {
- "method": "PUT",
- "path": "/api/v1/rrc/hubs/order"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/rrc/hubs/{hub_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/command"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/connect"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/disconnect"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
- },
- {
- "method": "PUT",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/servers/{hub_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/rrc/servers/{hub_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/activity"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/members"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/moderate"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/rooms"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/servers/{hub_id}/rooms/{room}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/self-test"
- },
- {
- "method": "GET",
- "path": "/api/v1/server/security"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/server/security"
- },
- {
- "method": "POST",
- "path": "/api/v1/setup/storage-migration"
- },
- {
- "method": "GET",
- "path": "/api/v1/sideband-plugins"
- },
- {
- "method": "GET",
- "path": "/api/v1/sideband-plugins/config"
- },
- {
- "method": "POST",
- "path": "/api/v1/sideband-plugins/config"
- },
- {
- "method": "POST",
- "path": "/api/v1/sideband-plugins/reload"
- },
- {
- "method": "GET",
- "path": "/api/v1/spam-keywords"
- },
- {
- "method": "POST",
- "path": "/api/v1/spam-keywords"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/spam-keywords/{keyword_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs/install"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs/reorder"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs/{pack_id}/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers"
- },
- {
- "method": "POST",
- "path": "/api/v1/stickers"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/stickers/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/stickers/{sticker_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/stickers/{sticker_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers/{sticker_id}/image"
- },
- {
- "method": "GET",
- "path": "/api/v1/system/network-interfaces"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/history/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/latest/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/peers"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/tracking"
- },
- {
- "method": "POST",
- "path": "/api/v1/telemetry/tracking/{destination_hash}/toggle"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/trusted-peers"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/answer"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/audio-profiles"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/call/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/codec2/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/contacts"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts/check/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/contacts/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/contacts/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/telephone/contacts/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/hangup"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/history"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/history"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/mute-receive"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/mute-transmit"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/recordings"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/recordings/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/recordings/{id}/audio/{side}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/ringtones/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/ringtones/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/telephone/ringtones/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones/{id}/audio"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/send-to-voicemail"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/switch-audio-profile/{profile_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/unmute-receive"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/unmute-transmit"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/generate-greeting"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/voicemail/greeting"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemail/greeting/audio"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/record/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/record/stop"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/upload"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemail/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemails"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/voicemails/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemails/{id}/audio"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemails/{id}/read"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/micron-parser-go-release"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/rnode/download_firmware"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/rnode/latest_release"
- },
- {
- "method": "POST",
- "path": "/api/v1/translator/install-languages"
- },
- {
- "method": "GET",
- "path": "/api/v1/translator/languages"
- },
- {
- "method": "POST",
- "path": "/api/v1/translator/translate"
- },
- {
- "method": "GET",
- "path": "/call.html"
- },
- {
- "method": "GET",
- "path": "/manifest.json"
- },
- {
- "method": "GET",
- "path": "/service-worker.js"
- },
- {
- "method": "GET",
- "path": "/ws"
- },
- {
- "method": "GET",
- "path": "/ws/telephone/audio"
- }
- ]
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/announces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/announces/query"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/app/changelog"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/changelog/seen"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/app/info"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/integrity/acknowledge"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/shutdown"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/tutorial/seen"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/csrf"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/login"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/logout"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/setup"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/blocked-destinations"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/blocked-destinations"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/blocked-destinations/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/announce"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/delete"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/restart"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/start"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/subprocess-log"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/bots/update"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/community-interfaces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/community-interfaces/refresh"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/comports"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/config"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/config"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/backup"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backup/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backups"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/database/backups/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backups/{filename}/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/health"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/recover"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/restore"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/snapshot"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/snapshots"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/database/snapshots/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/snapshots/{filename}/download"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/vacuum"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/debug/access-attempts"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/debug/logs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/custom-display-name"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/custom-display-name/update"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/drop-path"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/lxmf-stamp-info"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/path"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/request-path"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/signal-metrics"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/gc"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/gc/collect"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/heap"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/referrers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/reset"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/snapshot"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/export/reticulum"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/search"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/switch"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/docs/version/{version}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/favourites"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/add"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/import"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/favourites/layout"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/favourites/layout"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/favourites/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/{destination_hash}/rename"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/gifs/{gif_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/gifs/{gif_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs/{gif_id}/image"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs/{gif_id}/use"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identities"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identities/create"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identities/export-all"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identities/switch"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/identities/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identity/backup/base32"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identity/backup/download"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identity/restore"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/interface-stats"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/licenses"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/reactions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/send"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf-messages/{hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/{hash}/cancel"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/{hash}/spam"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/{message_hash}/uri"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/conversation-pins"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversation-pins/toggle"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/conversations"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/bulk-delete"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/bulk-mark-as-read"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/move-to-folder"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/{destination_hash}/mark-as-read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/folders"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/folders"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/folders/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/folders/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf/folders/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/lxmf/folders/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/message-blocklist/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/propagation-node/restart"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/propagation-node/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/stop-sync"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/sync"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-nodes"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/sieve-filters"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/sieve-filters"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/announces"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/archives"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/docs/reticulum"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/favourites"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/gifs"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/lxmf-icons"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/messages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/maintenance/messages/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import-file"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/path-table"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/stickers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/drawings"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/drawings"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/drawings/{drawing_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/map/drawings/{drawing_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/export"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/export/{export_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/export/{export_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/export/{export_id}/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/mbtiles"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/mbtiles/active"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/mbtiles/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/offline"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/offline"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/jobs/{job_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/jobs/{job_id}/cancel"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/overlays/{overlay_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/map/overlays/{overlay_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/{overlay_id}/content"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/overlays/{overlay_id}/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/overlays/{overlay_id}/refresh"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/tiles/{z}/{x}/{y}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/meshchatx-docs/content"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/meshchatx-docs/list"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/nomadnet/archives"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/nomadnet/archives"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/nomadnetwork/{destination_hash}/identify"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notification-sounds"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notification-sounds/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/notification-sounds/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/notification-sounds/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/notification-sounds/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notification-sounds/{id}/audio"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notifications"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/notifications/mark-as-viewed"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/files"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/files"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}/files/{file_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/pages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/pages"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/page-nodes/{node_id}/rename"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/path-table"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/path-table"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/install"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/preview"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins/trusted-publishers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/trusted-publishers"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/plugins/trusted-publishers/{identity}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/plugins/{plugin_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/disable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/enable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/invoke"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/report-failure"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/restart"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/repository-server/list"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/refresh-bundled"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/repository-server/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/repository-server/upload/{name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/blackhole"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/config/raw"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/reticulum/config/raw"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/config/reset"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/disable-transport"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/discovered-interfaces"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/discovery"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/reticulum/discovery"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/enable-transport"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/instance"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/reticulum/instance"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/interface-modules"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interface-modules"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/reticulum/interface-modules/{type_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/interfaces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/add"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/bitrates"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/delete"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/disable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/enable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/import-preview"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/management-identities"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/management-identities"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/recover"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/reload"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/cancel"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/fetch"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/listen"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/send"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rncp/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rncp/transfer/{transfer_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop-queues"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop-via"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/rates"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/request"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/table"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/trace/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnprobe"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnsh/sessions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rnsh/sessions/{session_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/clear"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/input"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnsh/sessions/{session_id}/output"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/resize"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnstatus"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnx/sessions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rnx/sessions/{session_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/clear"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/input"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnx/sessions/{session_id}/output"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/resize"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnx/sessions/{session_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/hubs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/rrc/hubs/order"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/rrc/hubs/{hub_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/command"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/connect"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/disconnect"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/servers/{hub_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/rrc/servers/{hub_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/activity"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/members"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/moderate"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/rooms"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/servers/{hub_id}/rooms/{room}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/self-test"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/server/security"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/server/security"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/setup/storage-migration"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sideband-plugins"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sideband-plugins/config"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sideband-plugins/config"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sideband-plugins/reload"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/spam-keywords"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/spam-keywords"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/spam-keywords/{keyword_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs/install"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs/reorder"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs/{pack_id}/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/stickers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/stickers/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/stickers/{sticker_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/stickers/{sticker_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers/{sticker_id}/image"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/system/network-interfaces"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/history/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/latest/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/peers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/tracking"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telemetry/tracking/{destination_hash}/toggle"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/trusted-peers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/answer"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/audio-profiles"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/call/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/codec2/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/contacts"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts/check/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/contacts/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/contacts/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/telephone/contacts/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/hangup"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/history"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/history"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/mute-receive"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/mute-transmit"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/recordings"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/recordings/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/recordings/{id}/audio/{side}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/ringtones/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/ringtones/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/telephone/ringtones/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones/{id}/audio"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/send-to-voicemail"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/switch-audio-profile/{profile_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/unmute-receive"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/unmute-transmit"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/generate-greeting"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/voicemail/greeting"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemail/greeting/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/record/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/record/stop"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/upload"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemail/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemails"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/voicemails/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemails/{id}/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemails/{id}/read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/micron-parser-go-release"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/rnode/download_firmware"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/rnode/latest_release"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/translator/install-languages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/translator/languages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/translator/translate"
+ },
+ {
+ "method": "GET",
+ "path": "/call.html"
+ },
+ {
+ "method": "GET",
+ "path": "/manifest.json"
+ },
+ {
+ "method": "GET",
+ "path": "/service-worker.js"
+ },
+ {
+ "method": "GET",
+ "path": "/ws"
+ },
+ {
+ "method": "GET",
+ "path": "/ws/telephone/audio"
+ }
+ ]
}
diff --git a/tests/backend/http_api_response_schemas.py b/tests/backend/http_api_response_schemas.py
index 60431f63..2a3ce679 100644
--- a/tests/backend/http_api_response_schemas.py
+++ b/tests/backend/http_api_response_schemas.py
@@ -751,6 +751,8 @@ TELEPHONE_CODEC2_STATUS_SCHEMA: dict = {
"properties": {
"codec2_available": {"type": "boolean"},
"preload_error": {"type": ["string", "null"]},
+ "probe_error": {"type": ["string", "null"]},
+ "platform": {"type": "string"},
"preferred_profile_id": {"type": ["integer", "null"]},
"resolved_profile_id": {"type": ["integer", "null"]},
},
diff --git a/tests/backend/test_android_codec2.py b/tests/backend/test_android_codec2.py
index 00909e46..6d7f6c15 100644
--- a/tests/backend/test_android_codec2.py
+++ b/tests/backend/test_android_codec2.py
@@ -9,8 +9,7 @@ from meshchatx import android_codec2
def test_ensure_codec2_skips_non_android():
- android_codec2._codec2_preload_done = False
- android_codec2._codec2_preload_error = None
+ android_codec2.reset_codec2_preload_state_for_tests()
with patch.object(android_codec2, "_is_chaquopy_android", return_value=False):
assert android_codec2.ensure_codec2_native_library() is True
assert android_codec2.codec2_preload_error() is None
@@ -20,14 +19,13 @@ def test_ensure_codec2_loads_bundled_library(tmp_path):
lib = tmp_path / "libcodec2.so"
lib.write_bytes(b"\x7fELF")
- android_codec2._codec2_preload_done = False
- android_codec2._codec2_preload_error = None
+ android_codec2.reset_codec2_preload_state_for_tests()
with (
patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
patch.object(
- android_codec2.ctypes,
- "CDLL",
+ android_codec2,
+ "_cdll_load",
side_effect=[OSError(), None],
) as cdll,
patch.object(
@@ -37,17 +35,43 @@ def test_ensure_codec2_loads_bundled_library(tmp_path):
),
):
assert android_codec2.ensure_codec2_native_library() is True
- cdll.assert_called_with(str(lib))
+ assert cdll.call_count == 2
+ assert cdll.call_args_list[0].args[0] == "libcodec2.so"
+ assert cdll.call_args_list[1].args[0] == str(lib)
+
+
+def test_libcodec2_candidates_find_without_importing_pycodec2(tmp_path, monkeypatch):
+ """Discovery must not import pycodec2 (extension needs libcodec2 already loaded)."""
+ site = tmp_path / "site-packages"
+ pkg = site / "pycodec2"
+ pkg.mkdir(parents=True)
+ lib = pkg / "libcodec2.so"
+ lib.write_bytes(b"\x7fELF")
+ monkeypatch.syspath_prepend(str(site))
+
+ import builtins
+
+ real_import = builtins.__import__
+
+ def fake_import(name, *args, **kwargs):
+ if name == "pycodec2" or name.startswith("pycodec2."):
+ raise ImportError("pycodec2 must not be imported during candidate search")
+ return real_import(name, *args, **kwargs)
+
+ with patch("builtins.__import__", side_effect=fake_import):
+ candidates = android_codec2._libcodec2_candidates()
+
+ assert lib.resolve() in [c.resolve() for c in candidates]
def test_probe_pycodec2_reports_failure_when_import_breaks():
+ android_codec2.reset_codec2_preload_state_for_tests()
android_codec2._codec2_preload_done = True
android_codec2._codec2_preload_error = None
with (
patch.object(android_codec2, "_is_chaquopy_android", return_value=False),
patch.dict("sys.modules", {"pycodec2": None}),
):
- # Force ImportError path by making import raise
import builtins
real_import = builtins.__import__
@@ -66,8 +90,6 @@ def test_probe_pycodec2_reports_failure_when_import_breaks():
def test_vendor_wheels_bundle_libcodec2_for_all_abis():
import zipfile
- import pytest
-
repo = Path(__file__).resolve().parents[2]
vendor = repo / "android" / "vendor"
if not vendor.is_dir():
@@ -100,8 +122,6 @@ def test_jni_libs_synced_for_all_abis():
def test_android_lxst_wheel_get_codec_guards_missing_codec2():
import zipfile
- import pytest
-
repo = Path(__file__).resolve().parents[2]
whl = repo / "android" / "vendor" / "lxst-0.4.8-py3-none-any.whl"
if not whl.is_file():
diff --git a/tests/backend/test_deep_links_security.py b/tests/backend/test_deep_links_security.py
index 30be8fb1..4bcbf80c 100644
--- a/tests/backend/test_deep_links_security.py
+++ b/tests/backend/test_deep_links_security.py
@@ -367,7 +367,9 @@ async def test_lxm_ingest_docs_hostname_spoof_not_docs_view(mock_app):
payload = json.loads(mock_client.send_str.call_args[0][0])
assert payload.get("ingest_type") != "docs_view"
- mock_app.message_router.ingest_lxm_uri.assert_called()
+ assert payload.get("ingest_type") == "unknown_meshchatx"
+ assert payload.get("status") == "error"
+ mock_app.message_router.ingest_lxm_uri.assert_not_called()
@pytest.mark.parametrize(
@@ -443,6 +445,30 @@ def test_meshchatx_docs_query_tail_fuzzing(mock_app, tail):
assert payload["ingest_type"] == "docs_view"
+@pytest.mark.asyncio
+async def test_unknown_meshchatx_host_does_not_fall_through_to_lxmf(mock_app):
+ mock_client = MagicMock()
+ mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
+ mock_app.message_router.ingest_lxm_uri = MagicMock()
+
+ with patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=lambda coro: asyncio.create_task(coro),
+ ):
+ await mock_app.on_websocket_data_received(
+ mock_client,
+ {"type": "lxm.ingest_uri", "uri": "meshchatx://not-a-real-host?x=1"},
+ )
+ await asyncio.sleep(0)
+
+ mock_app.message_router.ingest_lxm_uri.assert_not_called()
+ payload = json.loads(mock_client.send_str.call_args[0][0])
+ assert payload["type"] == "lxm.ingest_uri.result"
+ assert payload["status"] == "error"
+ assert payload["ingest_type"] == "unknown_meshchatx"
+ assert payload["host"] == "not-a-real-host"
+
+
def test_telemetry_pack_location_xss_like_strings_return_none():
from meshchatx.src.backend.telemetry_utils import Telemeter
diff --git a/tests/backend/test_meshchat_coverage.py b/tests/backend/test_meshchat_coverage.py
index 2d291044..863f4564 100644
--- a/tests/backend/test_meshchat_coverage.py
+++ b/tests/backend/test_meshchat_coverage.py
@@ -114,7 +114,13 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
fake_identity = MagicMock()
fake_identity.hash = bytes.fromhex("bb" * 16)
- fake_identity.load_public_key.return_value = True
+ fake_identity.pub = object()
+ fake_identity.get_public_key.return_value = b"\x11" * 64
+
+ def load_public_key(_key_bytes):
+ return None
+
+ fake_identity.load_public_key.side_effect = load_public_key
mock_app.config.auth_enabled.get.return_value = False
@@ -124,6 +130,7 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
side_effect=lambda coro: asyncio.create_task(coro),
),
patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity),
+ patch("meshchatx.meshchat.RNS.Identity.remember") as remember_mock,
):
await mock_app.on_websocket_data_received(
mock_client,
@@ -139,6 +146,7 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
lxmf_address="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
+ remember_mock.assert_called_once()
mock_app.message_router.ingest_lxm_uri.assert_not_called()
mock_client.send_str.assert_called_once()
payload = json.loads(mock_client.send_str.call_args[0][0])
@@ -160,9 +168,18 @@ async def test_lxm_ingest_uri_lxma_accepts_128_hex_public_key(mock_app):
fake_identity = MagicMock()
fake_identity.hash = bytes.fromhex("bb" * 16)
+ fake_identity.pub = object()
+ fake_identity.get_public_key.return_value = b"\x01" * 64
def load_public_key(key_bytes):
- return len(key_bytes) == 64
+ # Match real RNS: return None even on success.
+ if len(key_bytes) != 64:
+ fake_identity.pub = None
+ fake_identity.hash = None
+ return None
+ fake_identity.pub = object()
+ fake_identity.hash = bytes.fromhex("bb" * 16)
+ return None
fake_identity.load_public_key.side_effect = load_public_key
@@ -174,6 +191,7 @@ async def test_lxm_ingest_uri_lxma_accepts_128_hex_public_key(mock_app):
side_effect=lambda coro: asyncio.create_task(coro),
),
patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity),
+ patch("meshchatx.meshchat.RNS.Identity.remember"),
):
await mock_app.on_websocket_data_received(
mock_client,
@@ -192,6 +210,19 @@ async def test_lxm_ingest_uri_lxma_accepts_128_hex_public_key(mock_app):
assert len(fake_identity.load_public_key.call_args[0][0]) == 64
+def test_identity_from_public_key_bytes_accepts_real_rns_none_return():
+ """Regression for issue #21: RNS load_public_key returns None on success."""
+ import RNS
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ source = RNS.Identity()
+ pub = source.get_public_key()
+ loaded = ReticulumMeshChat._identity_from_public_key_bytes(pub)
+ assert loaded is not None
+ assert loaded.hash == source.hash
+ assert ReticulumMeshChat._identity_from_public_key_bytes(b"\x00" * 8) is None
+
+
@pytest.mark.asyncio
async def test_on_lxmf_sending_state_updated(mock_app):
mock_msg = MagicMock()
diff --git a/tests/e2e/shell.spec.js b/tests/e2e/shell.spec.js
index 39ae759a..c18675ca 100644
--- a/tests/e2e/shell.spec.js
+++ b/tests/e2e/shell.spec.js
@@ -1,11 +1,7 @@
const { test, expect } = require("@playwright/test");
const { prepareE2eSession } = require("./helpers");
-function topChrome(page) {
- return page.locator("div.sticky.top-0.z-\\[100\\]").first();
-}
-
-test.describe("Shell: sidebar, theme, notifications, call, search", () => {
+test.describe("Shell: sidebar, theme, call, search", () => {
test.beforeEach(async ({ request }) => {
await prepareE2eSession(request);
});
@@ -34,23 +30,6 @@ test.describe("Shell: sidebar, theme, notifications, call, search", () => {
await expect.poll(async () => shell.evaluate((el) => el.classList.contains("dark"))).toBe(initialDark);
});
- test("notification bell opens panel and closes from header", async ({ page }) => {
- await page.goto("/#/messages");
- await topChrome(page)
- .locator("button")
- .filter({ has: page.locator('svg[aria-label="bell"]') })
- .click();
- await expect(page.getByRole("heading", { name: "Notifications", exact: true })).toBeVisible({
- timeout: 15000,
- });
- await expect(page.getByText("No new notifications", { exact: true })).toBeVisible({ timeout: 10000 });
- const panel = page.locator("div.fixed").filter({ hasText: "Notifications" }).first();
- await panel.locator('svg[aria-label="close"]').click();
- await expect(page.getByRole("heading", { name: "Notifications", exact: true })).toBeHidden({
- timeout: 5000,
- });
- });
-
test("call route shows Phone tab", async ({ page }) => {
await page.goto("/#/call");
await expect(page).toHaveURL(/#\/call/);
diff --git a/tests/frontend/AppModals.test.js b/tests/frontend/AppModals.test.js
index 09a39a93..e48ecccb 100644
--- a/tests/frontend/AppModals.test.js
+++ b/tests/frontend/AppModals.test.js
@@ -120,7 +120,6 @@ describe("App.vue Modals", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
- NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
@@ -181,7 +180,6 @@ describe("App.vue Modals", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
- NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
@@ -216,7 +214,6 @@ describe("App.vue Modals", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
- NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
@@ -262,7 +259,6 @@ describe("App.vue Modals", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
- NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
diff --git a/tests/frontend/AppSidebarIdentityAnnounce.test.js b/tests/frontend/AppSidebarIdentityAnnounce.test.js
index e11d3a77..a2d1868d 100644
--- a/tests/frontend/AppSidebarIdentityAnnounce.test.js
+++ b/tests/frontend/AppSidebarIdentityAnnounce.test.js
@@ -54,7 +54,6 @@ const routes = [
const appStubs = {
MaterialDesignIcon: { template: '<span class="md-stub" />' },
LxmfUserIcon: { template: "<div />" },
- NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
diff --git a/tests/frontend/CallCodec2Regressions.test.js b/tests/frontend/CallCodec2Regressions.test.js
index 430045df..6ccccdee 100644
--- a/tests/frontend/CallCodec2Regressions.test.js
+++ b/tests/frontend/CallCodec2Regressions.test.js
@@ -229,7 +229,6 @@ describe("App telephone_ringing policy regressions", () => {
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
- NotificationBell: true,
LanguageSelector: true,
CallOverlay: true,
CommandPalette: true,
diff --git a/tests/frontend/CallOverlay.test.js b/tests/frontend/CallOverlay.test.js
index bb3bfa8b..56d981f9 100644
--- a/tests/frontend/CallOverlay.test.js
+++ b/tests/frontend/CallOverlay.test.js
@@ -157,4 +157,34 @@ describe("CallOverlay.vue", () => {
expect(wrapper.text()).toContain("5 GB");
expect(wrapper.text()).toContain("500 MB");
});
+
+ it("navigates to Call phone tab after answering", async () => {
+ const push = vi.fn().mockResolvedValue(undefined);
+ global.api = { get: vi.fn().mockResolvedValue({}) };
+ const wrapper = mount(CallOverlay, {
+ props: {
+ ...defaultProps,
+ activeCall: {
+ ...defaultProps.activeCall,
+ is_incoming: true,
+ status: 4,
+ },
+ },
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $router: { push },
+ $route: { name: "messages", query: {} },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ LxmfUserIcon: true,
+ AudioWaveformPlayer: true,
+ },
+ },
+ });
+ await wrapper.vm.answerCall();
+ expect(global.api.get).toHaveBeenCalledWith("/api/v1/telephone/answer");
+ expect(push).toHaveBeenCalledWith({ name: "call", query: { tab: "phone" } });
+ });
});
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index 3611cf86..6b301afd 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -122,6 +122,44 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.emitted("reload-conversations")).toBeFalsy();
});
+ it("markConversationAsRead force marks read even when local conversation looks already read", async () => {
+ const wrapper = mountConversationViewer();
+ await flushPromises();
+ axiosMock.post.mockClear();
+ GlobalEmitter.emit.mockClear();
+
+ const conversation = { destination_hash: "open-hash", is_unread: false };
+ await wrapper.vm.markConversationAsRead(conversation, { force: true });
+ await flushPromises();
+
+ expect(conversation.is_unread).toBe(false);
+ const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
+ expect(markCalls).toHaveLength(1);
+ expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
+ });
+
+ it("onLxmfMessageReceived force marks the open conversation as read", async () => {
+ const conversations = [{ destination_hash: "open-peer", is_unread: false }];
+ const wrapper = mountConversationViewer({
+ selectedPeer: { destination_hash: "open-peer", display_name: "Open" },
+ conversations,
+ });
+ await flushPromises();
+ axiosMock.post.mockClear();
+
+ wrapper.vm.onLxmfMessageReceived({
+ source_hash: "open-peer",
+ hash: "msg-1",
+ content: "hello",
+ timestamp: 1,
+ });
+ await flushPromises();
+
+ const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
+ expect(markCalls).toHaveLength(1);
+ expect(conversations[0].is_unread).toBe(false);
+ });
+
it("markConversationAsRead marks read without reloading conversations when conversation is unread", async () => {
const wrapper = mountConversationViewer();
await flushPromises();
diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index 968c7ecb..7cd1bdfa 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -606,7 +606,7 @@ describe("MessagesPage.vue", () => {
expect(wrapper.vm.selectedPeer.display_name).toBe("Existing Peer");
});
- it("onBulkMarkAsRead notifies notification bell after server confirms", async () => {
+ it("onBulkMarkAsRead emits notifications-changed after server confirms", async () => {
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
axiosMock.post.mockResolvedValue({ data: {} });
diff --git a/tests/frontend/NotificationBell.test.js b/tests/frontend/NotificationBell.test.js
deleted file mode 100644
index c0f41553..00000000
--- a/tests/frontend/NotificationBell.test.js
+++ /dev/null
@@ -1,940 +0,0 @@
-import { mount } from "@vue/test-utils";
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
-
-let wsHandlers = {};
-let emitterHandlers = {};
-vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
- default: {
- on: vi.fn((event, handler) => {
- wsHandlers[event] = wsHandlers[event] || [];
- wsHandlers[event].push(handler);
- }),
- off: vi.fn((event, handler) => {
- if (wsHandlers[event]) {
- wsHandlers[event] = wsHandlers[event].filter((h) => h !== handler);
- }
- }),
- },
-}));
-
-vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
- default: {
- on: vi.fn((event, handler) => {
- emitterHandlers[event] = emitterHandlers[event] || [];
- emitterHandlers[event].push(handler);
- }),
- off: vi.fn((event, handler) => {
- if (emitterHandlers[event]) {
- emitterHandlers[event] = emitterHandlers[event].filter((h) => h !== handler);
- }
- }),
- emit: vi.fn((event, payload) => {
- (emitterHandlers[event] || []).forEach((h) => h(payload));
- }),
- },
-}));
-
-vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
- default: { formatTimeAgo: (d) => "1h ago" },
-}));
-
-const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
-
-function mountBell(options = {}) {
- return mount(NotificationBell, {
- global: {
- components: { MaterialDesignIcon },
- directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
- mocks: {
- $router: { push: vi.fn() },
- $t: (key) => {
- const map = {
- "app.notifications_no_new": "No new notifications",
- "app.notifications_empty_history": "No notification history",
- "app.notifications_history_title": "Recent notification history",
- };
- return map[key] || key;
- },
- },
- },
- ...options,
- });
-}
-
-function simulateWsMessage(type, extra = {}) {
- const payload = { type, ...extra };
- if (type === "lxmf.delivery" && payload.lxmf_message === undefined) {
- // Default to a user-facing inbound text message so the bell will
- // reload. Tests that want to exercise the false-trigger path pass an
- // explicit lxmf_message override (reaction, telemetry, empty, etc.).
- payload.lxmf_message = { is_incoming: true, content: "hello", title: "", fields: {} };
- }
- const data = JSON.stringify(payload);
- (wsHandlers["message"] || []).forEach((h) => h({ data }));
-}
-
-describe("NotificationBell UI", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- global.api.post = vi.fn().mockResolvedValue({ data: {} });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- });
-
- it("renders bell button", () => {
- const wrapper = mountBell();
- const btn = wrapper.find("button.relative.rounded-full");
- expect(btn.exists()).toBe(true);
- });
-
- it("shows unread badge when unreadCount > 0", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- wrapper.vm.unreadCount = 5;
- await wrapper.vm.$nextTick();
- expect(wrapper.text()).toContain("5");
- });
-
- it("shows 9+ when unreadCount > 9", async () => {
- const wrapper = mountBell();
- wrapper.vm.unreadCount = 12;
- await wrapper.vm.$nextTick();
- expect(wrapper.text()).toContain("9+");
- });
-
- it("opens dropdown on button click", async () => {
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button").trigger("click");
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.isDropdownOpen).toBe(true);
- expect(document.body.textContent).toContain("Notifications");
- wrapper.unmount();
- });
-
- it("shows Clear button when dropdown open and notifications exist", async () => {
- global.api.get = vi.fn().mockResolvedValue({
- data: {
- notifications: [
- { destination_hash: "h1", display_name: "A", updated_at: new Date().toISOString(), content: "Hi" },
- ],
- unread_count: 1,
- },
- });
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button").trigger("click");
- await wrapper.vm.$nextTick();
- await new Promise((r) => setTimeout(r, 50));
- expect(document.body.textContent).toContain("Clear");
- wrapper.unmount();
- });
-
- it("shows No new notifications when empty", async () => {
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button").trigger("click");
- await wrapper.vm.$nextTick();
- await new Promise((r) => setTimeout(r, 150));
- expect(document.body.textContent).toContain("No new notifications");
- wrapper.unmount();
- });
-
- it("opening empty dropdown adds one notifications fetch after mount", async () => {
- global.api.get = vi.fn().mockResolvedValue({
- data: { notifications: [], unread_count: 0 },
- });
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.vm.$nextTick();
- const notifGetsAfterMount = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
- await wrapper.find("button").trigger("click");
- await new Promise((r) => setTimeout(r, 150));
- const notifGetsAfterOpen = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
- expect(notifGetsAfterOpen - notifGetsAfterMount).toBe(1);
- wrapper.unmount();
- });
-
- it("dropdown has Notifications heading when open", async () => {
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button").trigger("click");
- await wrapper.vm.$nextTick();
- const h3 = document.body.querySelector("h3");
- expect(h3?.textContent).toBe("Notifications");
- wrapper.unmount();
- });
-});
-
-describe("NotificationBell websocket reliability", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- global.api.post = vi.fn().mockResolvedValue({ data: {} });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- });
-
- it("reloads on lxmf.delivery websocket event", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
-
- global.api.get = vi.fn().mockResolvedValue({
- data: { notifications: [{ destination_hash: "d1", display_name: "X", content: "msg" }], unread_count: 1 },
- });
-
- simulateWsMessage("lxmf.delivery");
- await new Promise((r) => setTimeout(r, 50));
-
- expect(wrapper.vm.unreadCount).toBe(1);
- });
-
- it("reloads on telephone_missed_call websocket event", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
-
- global.api.get = vi.fn().mockResolvedValue({
- data: {
- notifications: [
- {
- id: 1,
- type: "telephone_missed_call",
- destination_hash: "c1",
- display_name: "Caller",
- content: "Missed",
- },
- ],
- unread_count: 1,
- },
- });
-
- simulateWsMessage("telephone_missed_call", { remote_identity_hash: "c1" });
- await new Promise((r) => setTimeout(r, 50));
-
- expect(wrapper.vm.unreadCount).toBe(1);
- });
-
- it("reloads on new_voicemail websocket event", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
-
- global.api.get = vi.fn().mockResolvedValue({
- data: {
- notifications: [
- {
- id: 2,
- type: "telephone_voicemail",
- destination_hash: "v1",
- display_name: "VM",
- content: "Voicemail",
- },
- ],
- unread_count: 1,
- },
- });
-
- simulateWsMessage("new_voicemail", { remote_identity_hash: "v1" });
- await new Promise((r) => setTimeout(r, 50));
-
- expect(wrapper.vm.unreadCount).toBe(1);
- });
-
- it("does NOT reload on unrelated websocket events", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const callsBefore = global.api.get.mock.calls.length;
-
- simulateWsMessage("telephone_ringing");
- simulateWsMessage("telephone_call_ended");
- simulateWsMessage("lxmf_message_state_updated");
- simulateWsMessage("lxmf.delivery", { lxmf_message: { is_incoming: false } });
- await new Promise((r) => setTimeout(r, 50));
-
- expect(global.api.get.mock.calls.length).toBe(callsBefore);
- });
-
- it("does NOT reload on outbound lxmf.delivery (delivery confirmation path)", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const callsBefore = global.api.get.mock.calls.length;
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: { is_incoming: false, state: "delivered" },
- });
- await new Promise((r) => setTimeout(r, 50));
- expect(global.api.get.mock.calls.length).toBe(callsBefore);
- });
-
- it("reloads on inbound lxmf.delivery", async () => {
- global.api.get = vi.fn().mockResolvedValue({
- data: { notifications: [], unread_count: 0 },
- });
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const callsAfterMount = global.api.get.mock.calls.length;
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: { is_incoming: true, content: "hi", title: "", fields: {} },
- });
- await new Promise((r) => setTimeout(r, 50));
- expect(global.api.get.mock.calls.length).toBeGreaterThan(callsAfterMount);
- const notifCalls = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
- expect(notifCalls.length).toBeGreaterThan(0);
- });
-
- it("rapid sequential websocket events all trigger reloads", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const initialCalls = global.api.get.mock.calls.length;
-
- for (let i = 0; i < 5; i++) {
- simulateWsMessage("lxmf.delivery");
- }
- await new Promise((r) => setTimeout(r, 100));
-
- expect(global.api.get.mock.calls.length).toBeGreaterThan(initialCalls);
- });
-
- it("ignores malformed websocket payload without crashing", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const initialCalls = global.api.get.mock.calls.length;
- (wsHandlers["message"] || []).forEach((h) => h({ data: "not-json{" }));
- await new Promise((r) => setTimeout(r, 30));
- expect(global.api.get.mock.calls.length).toBe(initialCalls);
- });
-});
-
-describe("NotificationBell false-trigger suppression", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- global.api.post = vi.fn().mockResolvedValue({ data: {} });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- });
-
- function expectNoNotificationsReload(callsBefore) {
- const notifGets = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
- expect(notifGets.length).toBe(callsBefore);
- }
-
- it("does NOT reload on inbound reaction (is_reaction flag)", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: {
- is_incoming: true,
- is_reaction: true,
- content: "",
- fields: { reaction: { reaction_to: "abc", reaction_content: "fire" } },
- },
- });
- await new Promise((r) => setTimeout(r, 30));
- expectNoNotificationsReload(before);
- });
-
- it("does NOT reload on inbound reaction signaled only via fields.reaction", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: {
- is_incoming: true,
- content: "",
- fields: { reaction: { reaction_to: "abc", reaction_content: "\u{1F44D}" } },
- },
- });
- await new Promise((r) => setTimeout(r, 30));
- expectNoNotificationsReload(before);
- });
-
- it("does NOT reload on inbound telemetry-only message", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: {
- is_incoming: true,
- content: "",
- title: "",
- fields: { telemetry: { something: 1 } },
- },
- });
- await new Promise((r) => setTimeout(r, 30));
- expectNoNotificationsReload(before);
- });
-
- it("does NOT reload on inbound icon-only / empty payload message", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: { is_incoming: true, content: "", title: "", fields: {} },
- });
- await new Promise((r) => setTimeout(r, 30));
- expectNoNotificationsReload(before);
- });
-
- it("does NOT reload when content is whitespace only", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: { is_incoming: true, content: " \n\t ", title: "", fields: {} },
- });
- await new Promise((r) => setTimeout(r, 30));
- expectNoNotificationsReload(before);
- });
-
- it("does NOT reload on lxmf.delivery without lxmf_message field", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
- (wsHandlers["message"] || []).forEach((h) => h({ data: JSON.stringify({ type: "lxmf.delivery" }) }));
- await new Promise((r) => setTimeout(r, 30));
- expectNoNotificationsReload(before);
- });
-
- it("does NOT reload on lxmf_message_state_updated (delivery status)", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf_message_state_updated", {
- lxmf_message: { is_incoming: false, state: "delivered" },
- });
- await new Promise((r) => setTimeout(r, 30));
- expectNoNotificationsReload(before);
- });
-
- it("DOES reload on real inbound text message", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: { is_incoming: true, content: "hello", title: "", fields: {} },
- });
- await new Promise((r) => setTimeout(r, 30));
- const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
- expect(after).toBeGreaterThan(before);
- });
-
- it("DOES reload on inbound title-only message", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: { is_incoming: true, content: "", title: "Subject", fields: {} },
- });
- await new Promise((r) => setTimeout(r, 30));
- const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
- expect(after).toBeGreaterThan(before);
- });
-
- it("DOES reload on inbound image attachment", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: {
- is_incoming: true,
- content: "",
- fields: { image: { image_size: 1024, image_type: "png" } },
- },
- });
- await new Promise((r) => setTimeout(r, 30));
- const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
- expect(after).toBeGreaterThan(before);
- });
-
- it("DOES reload on inbound audio attachment", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: {
- is_incoming: true,
- content: "",
- fields: { audio: { audio_size: 4242, audio_mode: 1 } },
- },
- });
- await new Promise((r) => setTimeout(r, 30));
- const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
- expect(after).toBeGreaterThan(before);
- });
-
- it("DOES reload on inbound file attachment", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const before = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
-
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: {
- is_incoming: true,
- content: "",
- fields: { file_attachments: [{ file_name: "x.txt", file_size: 5 }] },
- },
- });
- await new Promise((r) => setTimeout(r, 30));
- const after = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications").length;
- expect(after).toBeGreaterThan(before);
- });
-
- it("isUserFacingLxmfDelivery method directly classifies common payloads", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const fn = wrapper.vm.isUserFacingLxmfDelivery;
- expect(fn(null)).toBe(false);
- expect(fn(undefined)).toBe(false);
- expect(fn({ is_incoming: false, content: "hello" })).toBe(false);
- expect(fn({ is_incoming: true, content: "hello" })).toBe(true);
- expect(fn({ is_incoming: true, content: "", title: "" })).toBe(false);
- expect(fn({ is_incoming: true, content: "", title: "Subject" })).toBe(true);
- expect(fn({ is_incoming: true, is_reaction: true, content: "still ignored" })).toBe(false);
- expect(
- fn({
- is_incoming: true,
- content: "",
- fields: { reaction: { reaction_to: "x", reaction_content: "\u{1F44D}" } },
- })
- ).toBe(false);
- expect(fn({ is_incoming: true, content: "", fields: { telemetry: { x: 1 } } })).toBe(false);
- expect(fn({ is_incoming: true, content: "", fields: { image: { image_size: 1 } } })).toBe(true);
- expect(fn({ is_incoming: true, content: "", fields: { audio: { audio_size: 1 } } })).toBe(true);
- expect(
- fn({
- is_incoming: true,
- content: "",
- fields: { file_attachments: [{ file_name: "a", file_size: 1 }] },
- })
- ).toBe(true);
- expect(fn({ is_incoming: true, content: "", fields: { file_attachments: [] } })).toBe(false);
- });
-
- it("badge stays at zero through a flood of reaction events", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- for (let i = 0; i < 25; i++) {
- simulateWsMessage("lxmf.delivery", {
- lxmf_message: {
- is_incoming: true,
- content: "",
- fields: { reaction: { reaction_to: `m${i}`, reaction_content: "\u{1F44D}" } },
- },
- });
- }
- await new Promise((r) => setTimeout(r, 80));
- expect(wrapper.vm.unreadCount).toBe(0);
- });
-});
-
-describe("NotificationBell badge accuracy", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- global.api.post = vi.fn().mockResolvedValue({ data: {} });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- });
-
- it("badge hidden when unread count is 0", async () => {
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- const badge = wrapper.find("span.bg-red-500");
- expect(badge.exists()).toBe(false);
- });
-
- it("badge shows exact count for 1-9", async () => {
- for (let n = 1; n <= 9; n++) {
- const wrapper = mountBell();
- wrapper.vm.unreadCount = n;
- await wrapper.vm.$nextTick();
- expect(wrapper.text()).toContain(String(n));
- }
- });
-
- it("badge shows 9+ for counts above 9", async () => {
- for (const n of [10, 50, 100, 999]) {
- const wrapper = mountBell();
- wrapper.vm.unreadCount = n;
- await wrapper.vm.$nextTick();
- expect(wrapper.text()).toContain("9+");
- expect(wrapper.text()).not.toContain(String(n));
- }
- });
-
- it("badge updates reactively when unreadCount changes", async () => {
- const wrapper = mountBell();
- wrapper.vm.unreadCount = 3;
- await wrapper.vm.$nextTick();
- expect(wrapper.text()).toContain("3");
-
- wrapper.vm.unreadCount = 0;
- await wrapper.vm.$nextTick();
- expect(wrapper.find("span.bg-red-500").exists()).toBe(false);
-
- wrapper.vm.unreadCount = 15;
- await wrapper.vm.$nextTick();
- expect(wrapper.text()).toContain("9+");
- });
-
- it("opening dropdown syncs unread count from server after mark-as-viewed", async () => {
- global.api.get = vi
- .fn()
- .mockResolvedValueOnce({
- data: {
- notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }],
- unread_count: 3,
- },
- })
- .mockResolvedValueOnce({
- data: {
- notifications: [{ destination_hash: "d1", display_name: "A", content: "m" }],
- unread_count: 3,
- },
- })
- .mockResolvedValue({
- data: { notifications: [], unread_count: 0 },
- });
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.vm.$nextTick();
-
- await wrapper.find("button").trigger("click");
- await new Promise((r) => setTimeout(r, 80));
-
- expect(wrapper.vm.unreadCount).toBe(0);
- wrapper.unmount();
- });
-
- it("API failure does not cause false badge", async () => {
- global.api.get = vi.fn().mockRejectedValue(new Error("Network error"));
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- await new Promise((r) => setTimeout(r, 50));
-
- expect(wrapper.vm.unreadCount).toBe(0);
- expect(wrapper.vm.notifications).toEqual([]);
- });
-
- it("API returning null/empty fields does not cause false badge", async () => {
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: null, unread_count: null } });
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- await new Promise((r) => setTimeout(r, 50));
-
- expect(wrapper.vm.unreadCount).toBe(0);
- });
-});
-
-describe("NotificationBell mark-as-viewed", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- global.api.post = vi.fn().mockResolvedValue({ data: {} });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- });
-
- it("calls mark-as-viewed API when dropdown is opened", async () => {
- global.api.get = vi.fn().mockResolvedValue({
- data: {
- notifications: [
- { type: "lxmf_message", destination_hash: "abc", display_name: "A", content: "x" },
- { type: "telephone_missed_call", id: 42, destination_hash: "mc", display_name: "B", content: "y" },
- ],
- unread_count: 2,
- },
- });
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button").trigger("click");
- await new Promise((r) => setTimeout(r, 100));
-
- const postCalls = global.api.post.mock.calls;
- const markCall = postCalls.find((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
- expect(markCall).toBeTruthy();
- expect(markCall[1].destination_hashes).toContain("abc");
- expect(markCall[1].notification_ids).toContain(42);
- wrapper.unmount();
- });
-
- it("skips mark-as-viewed when no notifications", async () => {
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button").trigger("click");
- await new Promise((r) => setTimeout(r, 50));
-
- const markCalls = global.api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
- expect(markCalls.length).toBe(0);
- wrapper.unmount();
- });
-});
-
-describe("NotificationBell history", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- global.api.post = vi.fn().mockResolvedValue({ data: {} });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- });
-
- it("shows history control when dropdown is open", async () => {
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button.relative.rounded-full").trigger("click");
- await wrapper.vm.$nextTick();
- const historyBtn = document.body.querySelector('[aria-label="Recent notification history"]');
- expect(historyBtn).toBeTruthy();
- wrapper.unmount();
- });
-
- it("requests unread=false when toggling history on", async () => {
- global.api.get = vi.fn().mockResolvedValue({
- data: {
- notifications: [
- {
- id: 9,
- type: "telephone_missed_call",
- destination_hash: "ab",
- display_name: "X",
- content: "missed",
- },
- ],
- unread_count: 0,
- },
- });
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.vm.$nextTick();
- await wrapper.find("button.relative.rounded-full").trigger("click");
- await new Promise((r) => setTimeout(r, 120));
- global.api.get.mockClear();
- await wrapper.vm.toggleHistory();
- await wrapper.vm.$nextTick();
- const notifCalls = global.api.get.mock.calls.filter((c) => c[0] === "/api/v1/notifications");
- expect(notifCalls.length).toBeGreaterThan(0);
- const lastParams = notifCalls[notifCalls.length - 1][1].params;
- expect(lastParams.unread).toBe(false);
- expect(wrapper.vm.showHistory).toBe(true);
- wrapper.unmount();
- });
-
- it("resets history mode when dropdown closes", async () => {
- const wrapper = mountBell({ attachTo: document.body });
- wrapper.vm.showHistory = true;
- wrapper.vm.closeDropdown();
- expect(wrapper.vm.showHistory).toBe(false);
- });
-
- it("shows empty history copy in history mode", async () => {
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button.relative.rounded-full").trigger("click");
- await new Promise((r) => setTimeout(r, 120));
- await wrapper.vm.toggleHistory();
- await wrapper.vm.$nextTick();
- await new Promise((r) => setTimeout(r, 50));
- expect(document.body.textContent).toContain("No notification history");
- wrapper.unmount();
- });
-});
-
-describe("NotificationBell clear all", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- global.api.post = vi.fn().mockResolvedValue({ data: {} });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- });
-
- it("clears all notifications and marks conversations as read", async () => {
- let callCount = 0;
- global.api.get = vi.fn().mockImplementation((url) => {
- if (url === "/api/v1/notifications") {
- callCount++;
- if (callCount <= 2) {
- return Promise.resolve({
- data: {
- notifications: [{ destination_hash: "x", display_name: "X", content: "m" }],
- unread_count: 1,
- },
- });
- }
- return Promise.resolve({ data: { notifications: [], unread_count: 0 } });
- }
- if (url === "/api/v1/lxmf/conversations") {
- return Promise.resolve({
- data: {
- conversations: [
- { destination_hash: "conv1", is_unread: true },
- { destination_hash: "conv2", is_unread: false },
- ],
- },
- });
- }
- return Promise.resolve({ data: {} });
- });
-
- const wrapper = mountBell({ attachTo: document.body });
- await wrapper.find("button").trigger("click");
- await new Promise((r) => setTimeout(r, 100));
-
- await wrapper.vm.clearAllNotifications();
- await new Promise((r) => setTimeout(r, 100));
-
- const readCalls = global.api.post.mock.calls.filter((c) => c[0]?.includes("/mark-as-read"));
- expect(readCalls.length).toBe(1);
- expect(readCalls[0][0]).toContain("conv1");
-
- wrapper.unmount();
- });
-});
-
-describe("NotificationBell live sync", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- emitterHandlers = {};
- global.api.get = vi.fn().mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- global.api.post = vi.fn().mockResolvedValue({ data: {} });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- });
-
- it("refreshes badge when conversations are marked read elsewhere", async () => {
- let callCount = 0;
- global.api.get = vi.fn().mockImplementation(() => {
- callCount++;
- if (callCount === 1) {
- return Promise.resolve({ data: { notifications: [], unread_count: 3 } });
- }
- return Promise.resolve({ data: { notifications: [], unread_count: 0 } });
- });
-
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- await new Promise((r) => setTimeout(r, 50));
- expect(wrapper.vm.unreadCount).toBe(3);
-
- (emitterHandlers["notifications-changed"] || []).forEach((h) => h());
- await new Promise((r) => setTimeout(r, 50));
-
- expect(wrapper.vm.unreadCount).toBe(0);
- expect(global.api.get.mock.calls.length).toBeGreaterThanOrEqual(2);
- wrapper.unmount();
- });
-
- it("subscribes to notifications-changed on mount and unsubscribes on destroy", () => {
- const wrapper = mountBell();
- expect(emitterHandlers["notifications-changed"]?.length).toBeGreaterThan(0);
- const handler = emitterHandlers["notifications-changed"][0];
- wrapper.unmount();
- expect(emitterHandlers["notifications-changed"] || []).not.toContain(handler);
- });
-
- it("polls unread count every 5s while dropdown is closed", async () => {
- vi.useFakeTimers();
- let unread = 4;
- global.api.get = vi
- .fn()
- .mockImplementation(() => Promise.resolve({ data: { notifications: [], unread_count: unread } }));
-
- const wrapper = mountBell();
- await vi.runOnlyPendingTimersAsync();
- expect(wrapper.vm.unreadCount).toBe(4);
-
- unread = 0;
- await vi.advanceTimersByTimeAsync(5000);
- await vi.runOnlyPendingTimersAsync();
-
- expect(wrapper.vm.unreadCount).toBe(0);
- expect(wrapper.vm.isDropdownOpen).toBe(false);
- expect(global.api.get.mock.calls.length).toBeGreaterThanOrEqual(2);
-
- vi.useRealTimers();
- wrapper.unmount();
- });
-
- it("keeps badge visible until server confirms read (no optimistic clear)", async () => {
- global.api.get = vi.fn().mockResolvedValue({
- data: {
- notifications: [
- { type: "lxmf_message", destination_hash: "d1", display_name: "A", latest_message_preview: "hi" },
- ],
- unread_count: 1,
- },
- });
-
- const wrapper = mountBell();
- await wrapper.vm.$nextTick();
- await new Promise((r) => setTimeout(r, 50));
- expect(wrapper.find("span.bg-red-500").exists()).toBe(true);
-
- (emitterHandlers["notifications-changed"] || []).forEach((h) => h());
- await new Promise((r) => setTimeout(r, 50));
- expect(wrapper.find("span.bg-red-500").exists()).toBe(true);
-
- global.api.get.mockResolvedValue({ data: { notifications: [], unread_count: 0 } });
- (emitterHandlers["notifications-changed"] || []).forEach((h) => h());
- await new Promise((r) => setTimeout(r, 50));
- expect(wrapper.find("span.bg-red-500").exists()).toBe(false);
-
- wrapper.unmount();
- });
-
- it("opening bell with already-read server state shows empty list and clears stale badge", async () => {
- global.api.get = vi.fn().mockResolvedValue({
- data: { notifications: [], unread_count: 0 },
- });
-
- const wrapper = mountBell({ attachTo: document.body });
- wrapper.vm.unreadCount = 5;
- await wrapper.vm.$nextTick();
- expect(wrapper.find("span.bg-red-500").text()).toBe("5");
-
- await wrapper.find("button").trigger("click");
- await new Promise((r) => setTimeout(r, 80));
-
- expect(wrapper.vm.unreadCount).toBe(0);
- expect(wrapper.vm.notifications).toEqual([]);
- expect(document.body.textContent).toContain("No new notifications");
- const markCalls = global.api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
- expect(markCalls).toHaveLength(0);
-
- wrapper.unmount();
- });
-});
diff --git a/tests/frontend/NotificationBellConversationSync.test.js b/tests/frontend/NotificationBellConversationSync.test.js
deleted file mode 100644
index 1d314017..00000000
--- a/tests/frontend/NotificationBellConversationSync.test.js
+++ /dev/null
@@ -1,313 +0,0 @@
-import { mount, flushPromises } from "@vue/test-utils";
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-
-let wsHandlers = {};
-let emitterHandlers = {};
-
-vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
- default: {
- on: vi.fn((event, handler) => {
- wsHandlers[event] = wsHandlers[event] || [];
- wsHandlers[event].push(handler);
- }),
- off: vi.fn((event, handler) => {
- if (wsHandlers[event]) {
- wsHandlers[event] = wsHandlers[event].filter((h) => h !== handler);
- }
- }),
- },
-}));
-
-vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({
- default: {
- on: vi.fn((event, handler) => {
- emitterHandlers[event] = emitterHandlers[event] || [];
- emitterHandlers[event].push(handler);
- }),
- off: vi.fn((event, handler) => {
- if (emitterHandlers[event]) {
- emitterHandlers[event] = emitterHandlers[event].filter((h) => h !== handler);
- }
- }),
- emit: vi.fn((event, payload) => {
- (emitterHandlers[event] || []).forEach((h) => h(payload));
- }),
- },
-}));
-
-vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({
- default: { formatTimeAgo: () => "1h ago" },
-}));
-
-import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
-import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
-import ConversationViewer from "../../meshchatx/src/frontend/components/messages/ConversationViewer.vue";
-
-const MaterialDesignIcon = { template: '<div class="mdi"></div>', props: ["iconName"] };
-
-const PEER_HASH = "bb".repeat(16);
-
-function simulateWsDelivery() {
- const data = JSON.stringify({
- type: "lxmf.delivery",
- lxmf_message: { is_incoming: true, content: "hello", title: "", fields: {} },
- });
- (wsHandlers["message"] || []).forEach((h) => h({ data }));
-}
-
-function mountBell() {
- return mount(NotificationBell, {
- global: {
- components: { MaterialDesignIcon },
- directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
- mocks: {
- $router: { push: vi.fn() },
- $t: (key) => {
- const map = {
- "app.notifications_no_new": "No new notifications",
- "app.notifications_empty_history": "No notification history",
- "app.notifications_history_title": "Recent notification history",
- };
- return map[key] || key;
- },
- },
- },
- });
-}
-
-function mountViewer() {
- return mount(ConversationViewer, {
- props: {
- selectedPeer: { destination_hash: PEER_HASH, display_name: "Peer" },
- myLxmfAddressHash: "aa".repeat(16),
- conversations: [{ destination_hash: PEER_HASH, display_name: "Peer", is_unread: true }],
- },
- global: {
- directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } },
- mocks: {
- $t: (key) => key,
- $route: { meta: {} },
- $router: { push: vi.fn() },
- },
- stubs: {
- MaterialDesignIcon: true,
- AddImageButton: true,
- AddAudioButton: true,
- SendMessageButton: true,
- ConversationDropDownMenu: true,
- PaperMessageModal: true,
- AudioWaveformPlayer: true,
- LxmfUserIcon: true,
- ConversationPeerHeader: true,
- ConversationMessageEntry: true,
- ConversationMessageListVirtual: true,
- },
- },
- });
-}
-
-function createNotificationsApiMock() {
- let conversationRead = false;
- const get = vi.fn().mockImplementation((_url, config) => {
- const unreadOnly = config?.params?.unread === true;
- if (!conversationRead) {
- const item = {
- type: "lxmf_message",
- destination_hash: PEER_HASH,
- display_name: "Peer",
- latest_message_preview: "hello",
- updated_at: new Date().toISOString(),
- };
- return Promise.resolve({
- data: {
- notifications: unreadOnly ? [item] : [item],
- unread_count: 1,
- },
- });
- }
- return Promise.resolve({
- data: {
- notifications: [],
- unread_count: 0,
- },
- });
- });
- const post = vi.fn().mockImplementation((url) => {
- if (String(url).includes("/mark-as-read")) {
- conversationRead = true;
- }
- return Promise.resolve({ data: {} });
- });
- return {
- get,
- post,
- markRead: () => {
- conversationRead = true;
- },
- isRead: () => conversationRead,
- };
-}
-
-describe("NotificationBell conversation read sync", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- wsHandlers = {};
- emitterHandlers = {};
- window.URL.createObjectURL = vi.fn(() => "blob:mock");
- vi.stubGlobal(
- "FileReader",
- vi.fn(function () {
- return { readAsDataURL: vi.fn() };
- })
- );
- vi.stubGlobal("localStorage", {
- getItem: vi.fn(),
- setItem: vi.fn(),
- removeItem: vi.fn(),
- });
- });
-
- afterEach(() => {
- wsHandlers = {};
- emitterHandlers = {};
- vi.unstubAllGlobals();
- });
-
- it("reproduces stale badge: delivery raises count, read in Messages clears without opening bell", async () => {
- const api = createNotificationsApiMock();
- window.api = { get: api.get, post: api.post };
-
- const bell = mountBell();
- await flushPromises();
- expect(bell.vm.unreadCount).toBe(1);
- expect(bell.find("span.bg-red-500").exists()).toBe(true);
-
- simulateWsDelivery();
- await flushPromises();
- expect(bell.vm.unreadCount).toBe(1);
-
- const viewer = mountViewer();
- await flushPromises();
- const conversation = { destination_hash: PEER_HASH, is_unread: true };
- await viewer.vm.markConversationAsRead(conversation);
- await flushPromises();
-
- expect(api.post).toHaveBeenCalledWith(`/api/v1/lxmf/conversations/${PEER_HASH}/mark-as-read`);
- expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
- expect(bell.vm.unreadCount).toBe(0);
- expect(bell.find("span.bg-red-500").exists()).toBe(false);
-
- bell.unmount();
- viewer.unmount();
- });
-
- it("stale badge clears on bell click when server already has zero unread (empty dropdown)", async () => {
- const api = createNotificationsApiMock();
- api.markRead();
- window.api = { get: api.get, post: api.post };
-
- const bell = mountBell();
- bell.vm.unreadCount = 3;
- await bell.vm.$nextTick();
- expect(bell.find("span.bg-red-500").text()).toBe("3");
-
- await bell.find("button").trigger("click");
- await flushPromises();
- await new Promise((r) => setTimeout(r, 80));
-
- expect(bell.vm.unreadCount).toBe(0);
- expect(bell.vm.notifications).toEqual([]);
- expect(document.body.textContent).toContain("No new notifications");
-
- const markCalls = api.post.mock.calls.filter((c) => c[0] === "/api/v1/notifications/mark-as-viewed");
- expect(markCalls).toHaveLength(0);
-
- bell.unmount();
- });
-
- it("background poll refreshes badge while dropdown stays closed", async () => {
- vi.useFakeTimers();
- const api = createNotificationsApiMock();
- window.api = { get: api.get, post: api.post };
-
- const bell = mountBell();
- await flushPromises();
- expect(bell.vm.unreadCount).toBe(1);
-
- api.markRead();
- await vi.advanceTimersByTimeAsync(5000);
- await flushPromises();
-
- expect(bell.vm.unreadCount).toBe(0);
- expect(bell.vm.isDropdownOpen).toBe(false);
-
- vi.useRealTimers();
- bell.unmount();
- });
-
- it("does not emit notifications-changed when mark-as-read API fails", async () => {
- const api = createNotificationsApiMock();
- api.post.mockImplementation((url) => {
- if (String(url).includes("/mark-as-read")) {
- return Promise.reject(new Error("network"));
- }
- return Promise.resolve({ data: {} });
- });
- window.api = { get: api.get, post: api.post };
-
- const viewer = mountViewer();
- await flushPromises();
- GlobalEmitter.emit.mockClear();
-
- const conversation = { destination_hash: PEER_HASH, is_unread: true };
- await viewer.vm.markConversationAsRead(conversation);
- await flushPromises();
-
- expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
- expect(conversation.is_unread).toBe(true);
-
- viewer.unmount();
- });
-
- it("badge count tracks API unread_count after websocket delivery", async () => {
- let unread = 0;
- window.api = {
- get: vi.fn().mockImplementation(() =>
- Promise.resolve({
- data: {
- notifications:
- unread > 0
- ? [
- {
- type: "lxmf_message",
- destination_hash: PEER_HASH,
- display_name: "Peer",
- latest_message_preview: "ping",
- },
- ]
- : [],
- unread_count: unread,
- },
- })
- ),
- post: vi.fn().mockResolvedValue({ data: {} }),
- };
-
- const bell = mountBell();
- await flushPromises();
- expect(bell.vm.unreadCount).toBe(0);
-
- unread = 2;
- simulateWsDelivery();
- await flushPromises();
- expect(bell.vm.unreadCount).toBe(2);
- expect(bell.find("span.bg-red-500").text()).toBe("2");
-
- unread = 0;
- GlobalEmitter.emit("notifications-changed");
- await flushPromises();
- expect(bell.vm.unreadCount).toBe(0);
-
- bell.unmount();
- });
-});
diff --git a/tests/frontend/UIThemeAndVisibility.test.js b/tests/frontend/UIThemeAndVisibility.test.js
index d0dfb6fc..bbba3f15 100644
--- a/tests/frontend/UIThemeAndVisibility.test.js
+++ b/tests/frontend/UIThemeAndVisibility.test.js
@@ -6,7 +6,6 @@ import SettingsPage from "../../meshchatx/src/frontend/components/settings/Setti
import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue";
import ChangelogModal from "../../meshchatx/src/frontend/components/ChangelogModal.vue";
-import NotificationBell from "../../meshchatx/src/frontend/components/NotificationBell.vue";
import LanguageSelector from "../../meshchatx/src/frontend/components/LanguageSelector.vue";
vi.mock("vuetify", () => ({
@@ -119,7 +118,6 @@ describe("Theme Switching", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -149,7 +147,6 @@ describe("Theme Switching", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -179,7 +176,6 @@ describe("Theme Switching", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -224,7 +220,6 @@ describe("Theme Switching", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -270,7 +265,6 @@ describe("Theme Switching", () => {
props: ["iconName"],
},
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -501,7 +495,6 @@ describe("Conditional Rendering", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -529,7 +522,6 @@ describe("Conditional Rendering", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -557,7 +549,6 @@ describe("Conditional Rendering", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -583,7 +574,6 @@ describe("Conditional Rendering", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: '<div data-icon-name="{{ iconName }}"></div>' },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
@@ -614,7 +604,6 @@ describe("Dark Mode Class Application", () => {
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: "<div></div>" },
LanguageSelector: { template: "<div></div>" },
- NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
diff --git a/tests/frontend/clampFloatingToViewport.test.js b/tests/frontend/clampFloatingToViewport.test.js
index fc9cc84a..ec76d0c0 100644
--- a/tests/frontend/clampFloatingToViewport.test.js
+++ b/tests/frontend/clampFloatingToViewport.test.js
@@ -80,11 +80,6 @@ describe("clampFloatingToViewport wiring", () => {
it.each([
["DropDownMenu.vue", "meshchatx/src/frontend/components/DropDownMenu.vue", 'ref="dropdownPanel"'],
["LanguageSelector.vue", "meshchatx/src/frontend/components/LanguageSelector.vue", 'ref="languageDropdown"'],
- [
- "NotificationBell.vue",
- "meshchatx/src/frontend/components/NotificationBell.vue",
- 'ref="notificationDropdown"',
- ],
[
"ConversationViewer.vue",
"meshchatx/src/frontend/components/messages/ConversationViewer.vue",
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────